From ae6ff939086d543b45f5299d90e3add40d090ab2 Mon Sep 17 00:00:00 2001 From: Utkarsh Singh <183999732+utksh1@users.noreply.github.com> Date: Thu, 25 Jun 2026 05:46:40 +0530 Subject: [PATCH 1/2] Revert all PR merges from session (PRs #1255, #1243, #1241, #1240, #1235, #1234, #1232, #1231, #1230, #1229, #1228, #1222, #1221, #1254, #1252, #1251, #1250, #1242, #1255, #1090, #1097, #1112) Reverting all PR merges performed during this session to restore repository to pre-session state. This reverts commits: - e376276 (#1255) - 18e5101 (#1222) - e516dd5 (#1230) - be400fa (#1235) - d522e9a (#1243) - bc0b1cd (#1090) - de56660 (#1097) - c2a5a53 (#1112) - eb30d33 (#1254) - f2a67f7 (#1250) - 6579112 (#1251) - 8751d92 (#1252) - 3ebc935 (#1221) - 6f5a936 (#1228) - da15da4 (#1229) - b66a5b0 (#1231) - 775c5c2 (#1232) - b529ddb (#1234) - 965b001 (#1241) - d46e573 (#1242) - dd09955 (#1240) All PRs are also being reopened and gssoc labels removed. --- PLUGINS.md | 6 - README.md | 4 - backend/secuscan/auth.py | 131 +------ backend/secuscan/config.py | 8 - backend/secuscan/executor.py | 4 - backend/secuscan/main.py | 97 +---- backend/secuscan/notification_service.py | 123 ------- backend/secuscan/rate_limiter.py | 185 ---------- backend/secuscan/routes.py | 38 +- docs/plugin-validation.md | 44 +-- .../plugins/plugin-development-walkthrough.md | 52 --- frontend/src/App.tsx | 24 +- frontend/src/api.ts | 51 +-- frontend/src/components/ApiKeySetupModal.tsx | 25 +- frontend/src/components/ApiKeySetupScreen.tsx | 28 +- frontend/src/hooks/useTaskSubscription.ts | 39 +- frontend/src/index.css | 16 - frontend/src/pages/Findings.tsx | 301 +++++----------- frontend/src/pages/NotFound.tsx | 89 ----- frontend/src/pages/Reports.tsx | 19 +- frontend/src/pages/Scans.tsx | 60 +--- frontend/src/pages/Settings.tsx | 21 +- frontend/src/pages/Toolkit.tsx | 6 +- frontend/src/utils/exportUtils.ts | 73 ---- frontend/tailwind.config.js | 10 - frontend/testing/unit/App.auth.gate.test.tsx | 102 ++---- frontend/testing/unit/AppRoutes.test.tsx | 32 +- frontend/testing/unit/api.auth.test.ts | 22 +- .../unit/components/ThemeToggle.test.tsx | 28 -- .../unit/hooks/useTaskSubscription.test.ts | 7 +- frontend/testing/unit/pages/Findings.test.tsx | 78 ---- .../unit/pages/Reports.onboarding.test.tsx | 83 ----- .../pages/Reports.preferredFormat.test.tsx | 6 +- frontend/testing/unit/pages/Reports.test.tsx | 27 +- frontend/testing/unit/pages/Scans.test.tsx | 25 -- .../testing/unit/utils/exportUtils.test.ts | 66 ---- .../integration/test_webhook_egress_policy.py | 221 ------------ testing/backend/test_rate_limiter.py | 293 --------------- testing/backend/unit/test_cli.py | 125 ------- .../test_finding_intelligence_asset_ref.py | 197 ---------- ...test_finding_intelligence_asset_summary.py | 214 ----------- .../test_finding_intelligence_confidence.py | 335 ------------------ .../test_finding_intelligence_timestamp.py | 125 ------- .../backend/unit/test_notification_service.py | 54 --- testing/backend/unit/test_ratelimit.py | 331 ++++++++--------- testing/backend/unit/test_sandbox_executor.py | 247 ++++--------- testing/backend/unit/test_saved_views.py | 82 +---- 47 files changed, 474 insertions(+), 3680 deletions(-) delete mode 100644 backend/secuscan/rate_limiter.py delete mode 100644 docs/plugins/plugin-development-walkthrough.md delete mode 100644 frontend/src/pages/NotFound.tsx delete mode 100644 frontend/src/utils/exportUtils.ts delete mode 100644 frontend/testing/unit/pages/Reports.onboarding.test.tsx delete mode 100644 frontend/testing/unit/utils/exportUtils.test.ts delete mode 100644 testing/backend/integration/test_webhook_egress_policy.py delete mode 100644 testing/backend/test_rate_limiter.py delete mode 100644 testing/backend/unit/test_finding_intelligence_asset_ref.py delete mode 100644 testing/backend/unit/test_finding_intelligence_asset_summary.py delete mode 100644 testing/backend/unit/test_finding_intelligence_confidence.py delete mode 100644 testing/backend/unit/test_finding_intelligence_timestamp.py diff --git a/PLUGINS.md b/PLUGINS.md index f24e3ee19..568a91630 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -1,11 +1,5 @@ # SecuScan Plugin Catalogue -## Plugin Development - -New contributors can follow the complete plugin creation walkthrough: - -docs/plugins/plugin-development-walkthrough.md - This file is a human-readable index of the plugins currently present in `plugins/*/metadata.json`. Last synced: 2026-05-11 diff --git a/README.md b/README.md index 8a40b8bcb..7c1a7b625 100644 --- a/README.md +++ b/README.md @@ -310,10 +310,6 @@ Before opening a plugin PR: 7. Refresh checksums. 8. Update `PLUGINS.md` if the catalogue changes. -For a complete plugin creation tutorial: - -docs/plugins/plugin-development-walkthrough.md - Start with [PLUGINS.md](PLUGINS.md), [docs/plugin-validation.md](docs/plugin-validation.md), and [CONTRIBUTING.md](CONTRIBUTING.md). ## Contributing diff --git a/backend/secuscan/auth.py b/backend/secuscan/auth.py index b4ef744cb..343f0e460 100644 --- a/backend/secuscan/auth.py +++ b/backend/secuscan/auth.py @@ -5,141 +5,20 @@ Clients must supply it via: - Authorization: Bearer - X-Api-Key: - -Session management (signed cookie, no server-side state): - - POST /api/v1/auth/session — validate API key and set HttpOnly session cookie - - GET /api/v1/auth/session/check — verify active session cookie - - POST /api/v1/auth/session/logout — clear session cookie """ -import base64 -import hmac -import json import os import secrets -import time from pathlib import Path -from fastapi import Depends, HTTPException, Security, status, Request, Response +from fastapi import Depends, HTTPException, Security, status, Request from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer -from fastapi import APIRouter _bearer_scheme = HTTPBearer(auto_error=False) _api_key_header = APIKeyHeader(name="X-Api-Key", auto_error=False) _api_key: str | None = None -SESSION_TTL_SECONDS = 3600 # 1 hour -COOKIE_NAME = "secuscan_session" -_SIGNING_KEY: bytes | None = None - - -def _init_signing_key() -> bytes: - global _SIGNING_KEY - if _SIGNING_KEY is None: - _SIGNING_KEY = secrets.token_bytes(32) - return _SIGNING_KEY - - -def _make_signed_token() -> str: - key = _init_signing_key() - expires = int(time.time()) + SESSION_TTL_SECONDS - payload = json.dumps({"s": secrets.token_urlsafe(16), "e": expires}, separators=(",", ":")).encode() - payload_b64 = base64.urlsafe_b64encode(payload).decode().rstrip("=") - sig = hmac.new(key, payload, "sha256").hexdigest() - return f"{payload_b64}.{sig}" - - -def _verify_signed_token(token: str) -> bool: - key = _init_signing_key() - try: - parts = token.split(".") - if len(parts) != 2: - return False - payload_b64, sig = parts - padding = 4 - len(payload_b64) % 4 - if padding != 4: - payload_b64 += "=" * padding - payload = base64.urlsafe_b64decode(payload_b64.encode()) - expected_sig = hmac.new(key, payload, "sha256").hexdigest() - if not secrets.compare_digest(expected_sig, sig): - return False - data = json.loads(payload) - if time.time() > data["e"]: - return False - return True - except Exception: - return False - - -def _cookie_secure(request: Request) -> bool: - forwarded_proto = request.headers.get("X-Forwarded-Proto", "") - if forwarded_proto.lower() == "https": - return True - return request.url.scheme == "https" - - -auth_router = APIRouter(prefix="/api/v1/auth") - - -@auth_router.post("/session") -async def create_session(request: Request, response: Response): - """Validate the API key and set an HttpOnly session cookie. - - The client sends the API key via the X-Api-Key header (or Authorization - Bearer). On success the server sets a signed HttpOnly session cookie so - the key itself never needs to touch localStorage. The cookie is self- - contained (HMAC-signed) and requires no server-side session store. - The Secure flag is only set when the request arrives over HTTPS or - carries an X-Forwarded-Proto: https header, preserving HTTP localhost - development. - """ - if _api_key is None: - raise HTTPException( - status_code=503, detail="Authentication service not initialised" - ) - - candidate = request.headers.get("X-Api-Key") - if not candidate: - bearer = request.headers.get("Authorization", "") - if bearer.lower().startswith("bearer "): - candidate = bearer[7:] - - if not candidate or not secrets.compare_digest(candidate, _api_key): - raise HTTPException(status_code=401, detail="Invalid API key") - - token = _make_signed_token() - response.set_cookie( - key=COOKIE_NAME, - value=token, - httponly=True, - secure=_cookie_secure(request), - samesite="strict", - max_age=SESSION_TTL_SECONDS, - ) - return {"status": "authenticated"} - - -@auth_router.get("/session/check") -async def check_session(request: Request): - """Return whether the request carries a valid signed session cookie.""" - token = request.cookies.get(COOKIE_NAME) - if token and _verify_signed_token(token): - return {"authenticated": True} - return {"authenticated": False} - - -@auth_router.post("/session/logout") -async def logout_session(request: Request, response: Response): - """Destroy the session cookie.""" - response.delete_cookie(COOKIE_NAME) - return {"status": "logged_out"} - - -def is_authenticated_by_session(request: Request) -> bool: - token = request.cookies.get(COOKIE_NAME) - return bool(token and _verify_signed_token(token)) - def init_api_key(data_dir: str) -> str: """ @@ -168,23 +47,17 @@ async def require_api_key( x_api_key: str | None = Security(_api_key_header), ) -> str: """ - FastAPI dependency — rejects requests that do not carry the correct API key - or a valid session cookie. + FastAPI dependency — rejects requests that do not carry the correct API key. Accepts the key in either: - ``Authorization: Bearer `` - ``X-Api-Key: `` - - Valid ``secuscan_session`` HttpOnly cookie (set via POST /auth/session) """ 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 "" - # Allow requests authenticated via session cookie - if request is not None and is_authenticated_by_session(request): - return "session-authenticated" - if _api_key is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index 5e7b7bacd..35bcf2823 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -86,11 +86,6 @@ class Settings(BaseSettings): max_tasks_per_hour: int = 50 max_requests_per_minute: int = 100 - scan_rate_limit: int = int(os.environ.get("SCAN_RATE_LIMIT", "5")) - scan_rate_window: int = int(os.environ.get("SCAN_RATE_WINDOW_SECONDS", "60")) - scan_burst_limit: int = int(os.environ.get("SCAN_BURST_LIMIT", "10")) - scan_burst_window: int = int(os.environ.get("SCAN_BURST_WINDOW_SECONDS", "3600")) - # Endpoint rate limiting buckets rate_limit_task_start_limit: int = 50 rate_limit_task_start_window: int = 3600 @@ -172,9 +167,6 @@ class Settings(BaseSettings): smtp_from_email: str = "noreply@secuscan.io" smtp_use_tls: bool = True - # Slack Webhook Configuration - slack_webhook_url: Optional[str] = None - class Config: env_prefix = "SECUSCAN_" case_sensitive = False diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index 3cb44e9de..90c43a982 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -1844,10 +1844,6 @@ async def _dispatch_task_notifications(self, db, task_id: str) -> None: ) if sent: logger.info("Task %s: delivered %d notification(s)", task_id, sent) - - # Send Slack Webhook notification for scan completion - from .notification_service import process_slack_notification - await process_slack_notification(db, task_id) except Exception as exc: logger.warning( "Task %s: notification dispatch failed: %s", diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index c1b650093..1cd16bd76 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -9,8 +9,8 @@ from contextlib import asynccontextmanager from .request_middleware import RequestIDMiddleware -from fastapi import FastAPI, Request, status -from fastapi.responses import HTMLResponse, PlainTextResponse, JSONResponse +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 ( @@ -19,11 +19,10 @@ ) from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException -from starlette.status import HTTP_429_TOO_MANY_REQUESTS from .request_context import get_request_id from .config import settings -from .auth import init_api_key, auth_router +from .auth import init_api_key from .cache import init_cache, cache as global_cache from .database import init_db, db as global_db from .routes import router @@ -31,9 +30,6 @@ from .workflows import scheduler from .plugins import init_plugins, get_plugin_check_latency_ms -# Import rate limiter -from .rate_limiter import make_scan_rate_limiter, RateLimitExceeded - logging.basicConfig( level=getattr(logging, settings.log_level), handlers=[ @@ -73,35 +69,6 @@ async def lifespan(app: FastAPI): await init_cache() logger.info("✓ In-memory cache initialized") - # ─── RATE LIMITER SETUP ────────────────────────────────────────────── - # Initialize rate limiter with Redis client from cache - # The cache client is stored in global_cache (which is a Redis client) - logger.info("🔒 Initializing rate limiter...") - - # Check if rate limiting is enabled - if getattr(settings, 'rate_limit_enabled', True): - try: - # Use the global_cache Redis client for rate limiting storage - app.state.scan_rate_limiter = make_scan_rate_limiter( - redis_client=global_cache._client if hasattr(global_cache, '_client') else global_cache, - rate_limit=getattr(settings, 'scan_rate_limit', '5/minute'), - rate_window=getattr(settings, 'scan_rate_window', 60), # 60 seconds - burst_limit=getattr(settings, 'scan_burst_limit', '10/hour'), - burst_window=getattr(settings, 'scan_burst_window', 3600), # 1 hour - ) - logger.info("✓ Rate limiter initialized successfully") - logger.info(f" Rate limit: {getattr(settings, 'scan_rate_limit', '5/minute')}") - logger.info(f" Burst limit: {getattr(settings, 'scan_burst_limit', '10/hour')}") - except Exception as e: - logger.error(f"Failed to initialize rate limiter: {e}") - # Set a dummy limiter that doesn't actually limit - app.state.scan_rate_limiter = None - logger.warning("⚠️ Rate limiting disabled due to initialization error") - else: - logger.info("⚠️ Rate limiting disabled by configuration") - app.state.scan_rate_limiter = None - # ─── END RATE LIMITER SETUP ────────────────────────────────────────── - # Load plugins await init_plugins(settings.plugins_dir) logger.info("✓ Plugins loaded") @@ -205,53 +172,6 @@ async def redirect_api_openapi(): ) app.add_middleware(RequestIDMiddleware) -# ─── CUSTOM 429 RATE LIMIT EXCEPTION HANDLER ────────────────────────────── -@app.exception_handler(RateLimitExceeded) -async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded): - """ - Custom handler for rate limit exceeded errors. - Returns a consistent JSON 429 response matching the API's error schema. - """ - logger.warning( - f"Rate limit exceeded for {request.client.host if request.client else 'unknown'} " - f"on {request.url.path} - {str(exc)}" - ) - - # Get retry-after from exception if available - retry_after = getattr(exc, 'retry_after', 60) - - return JSONResponse( - status_code=HTTP_429_TOO_MANY_REQUESTS, - content={ - "error": str(exc.detail) if hasattr(exc, 'detail') else "Too Many Requests", - "retry_after": retry_after, - "message": "Rate limit exceeded. Please wait before making more requests." - }, - headers={ - "Retry-After": str(retry_after), - "X-Request-ID": getattr(request.state, "request_id", get_request_id()), - }, - ) - -# Also handle generic 429 exceptions (for compatibility) -@app.exception_handler(HTTP_429_TOO_MANY_REQUESTS) -async def generic_rate_limit_handler(request: Request, exc: Exception): - """ - Generic handler for 429 status code exceptions. - """ - return JSONResponse( - status_code=HTTP_429_TOO_MANY_REQUESTS, - content={ - "error": "Too Many Requests", - "message": "Rate limit exceeded. Please try again later." - }, - headers={ - "Retry-After": "60", - "X-Request-ID": getattr(request.state, "request_id", get_request_id()), - }, - ) -# ─── END CUSTOM 429 HANDLER ────────────────────────────────────────────────── - @app.exception_handler(StarletteHTTPException) async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException): response = await http_exception_handler(request, exc) @@ -280,7 +200,6 @@ async def custom_unhandled_exception_handler(request: Request, exc: Exception): return response # Include API routes -app.include_router(auth_router) app.include_router(router) app.include_router(saved_views_router) @@ -292,9 +211,6 @@ async def health_check(): import platform import sys - # Check rate limiter status - rate_limiter_status = "enabled" if hasattr(app.state, 'scan_rate_limiter') and app.state.scan_rate_limiter else "disabled" - logger.info("Health check endpoint accessed") return { "status": "operational", @@ -304,11 +220,6 @@ async def health_check(): "python_version": sys.version.split()[0], "docker_available": shutil.which("docker") is not None, }, - "rate_limiting": { - "status": rate_limiter_status, - "rate_limit": getattr(settings, 'scan_rate_limit', '5/minute'), - "burst_limit": getattr(settings, 'scan_burst_limit', '10/hour'), - }, "plugin_check_latency_ms": get_plugin_check_latency_ms(), } @@ -348,4 +259,4 @@ def main(): ) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/backend/secuscan/notification_service.py b/backend/secuscan/notification_service.py index 03365bda5..ad65c0182 100644 --- a/backend/secuscan/notification_service.py +++ b/backend/secuscan/notification_service.py @@ -652,126 +652,3 @@ async def process_task_notifications( for row in findings: results.extend(await process_finding_notifications(db, str(row["id"]))) return results - - -async def process_slack_notification(db: Database, task_id: str) -> None: - """Send a structured JSON payload and a clean block message to the configured Slack Webhook after scan completion.""" - from .config import settings - - webhook_url = settings.slack_webhook_url - if not webhook_url: - return - - # Fetch task details - task = await db.fetchone("SELECT * FROM tasks WHERE id = ?", (task_id,)) - if not task: - logger.warning("Slack notification: Task %s not found in database", task_id) - return - - status = str(task.get("status") or "unknown").upper() - tool_name = task.get("tool_name") or task.get("plugin_id") or "Security Scan" - target = task.get("target") or "Unknown Target" - duration = task.get("duration_seconds") - duration_str = f"{duration:.2f}s" if duration is not None else "N/A" - - # Fetch findings to count and build severity breakdown - findings = await db.fetchall( - "SELECT severity FROM findings WHERE task_id = ?", - (task_id,), - ) - total_findings = len(findings) - - severity_counts: Dict[str, int] = {} - for row in findings: - sev = str(row.get("severity") or "info").lower() - severity_counts[sev] = severity_counts.get(sev, 0) + 1 - - # Formulate severity breakdown message - severity_lines = [] - for sev in ["critical", "high", "medium", "low", "info"]: - count = severity_counts.get(sev, 0) - if count > 0 or sev in ["critical", "high", "medium"]: - severity_lines.append(f"• *{sev.capitalize()}:* {count}") - severity_text = "\n".join(severity_lines) - - # Status-specific formatting - status_icon = "✅" if status == "COMPLETED" else "❌" if status == "FAILED" else "ℹ️" - - blocks = [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": f"{status_icon} SecuScan: Scan {status.capitalize()}", - "emoji": True - } - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": f"*Tool:*\n{tool_name}" - }, - { - "type": "mrkdwn", - "text": f"*Target:*\n{target}" - }, - { - "type": "mrkdwn", - "text": f"*Status:*\n{status}" - }, - { - "type": "mrkdwn", - "text": f"*Duration:*\n{duration_str}" - } - ] - } - ] - - if status == "FAILED" and task.get("error_message"): - blocks.append({ - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"*Error Message:*\n{task.get('error_message')}" - } - }) - else: - blocks.append({ - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": f"*Total Findings:*\n{total_findings}" - }, - { - "type": "mrkdwn", - "text": f"*Severity Breakdown:*\n{severity_text}" - } - ] - }) - - payload = { - "text": f"SecuScan scan of {target} finished with status: {status}", - "blocks": blocks, - "scan_data": { - "task_id": task_id, - "tool_name": tool_name, - "target": target, - "status": status.lower(), - "duration_seconds": duration, - "total_findings": total_findings, - "severity_counts": severity_counts, - "error_message": task.get("error_message") - } - } - - try: - ok, error = await send_webhook(webhook_url, payload) - if ok: - logger.info("Slack notification for task %s sent successfully", task_id) - else: - logger.warning("Failed to send Slack notification for task %s: %s", task_id, error) - except Exception as exc: - logger.error("Error sending Slack notification for task %s: %s", task_id, exc, exc_info=True) diff --git a/backend/secuscan/rate_limiter.py b/backend/secuscan/rate_limiter.py deleted file mode 100644 index 60f5091aa..000000000 --- a/backend/secuscan/rate_limiter.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -backend/secuscan/rate_limiter.py - -Redis-backed sliding window rate limiter for scan execution endpoints. - -Algorithm: Sliding window counter using Redis INCR + EXPIRE. -- Per-IP counters stored as Redis keys with TTL. -- Two-tier limits: per-minute (burst protection) and per-hour (sustained limit). -- Returns HTTP 429 with Retry-After header when limits are exceeded. -- When Redis is unavailable, fails OPEN (allows request) and logs a warning, - so a Redis outage does not take down the scan service entirely. - -Key schema: - rate_limit:scan:{ip}:minute:{window_start_minute} → request count - rate_limit:scan:{ip}:hour:{window_start_hour} → request count -""" - -import logging -import time -from typing import Optional - -import redis.asyncio as aioredis -from fastapi import HTTPException, Request, status - -logger = logging.getLogger(__name__) - - -class ScanRateLimiter: - """ - Sliding window rate limiter for scan execution endpoints. - - Usage: - limiter = ScanRateLimiter(redis_client, rate_limit=5, rate_window=60, - burst_limit=10, burst_window=3600) - await limiter.check(request) # raises HTTP 429 if limit exceeded - """ - - def __init__( - self, - redis_client: Optional[aioredis.Redis], - rate_limit: int, - rate_window: int, - burst_limit: int, - burst_window: int, - ) -> None: - self._redis = redis_client - self._rate_limit = rate_limit # e.g. 5 requests - self._rate_window = rate_window # e.g. per 60 seconds - self._burst_limit = burst_limit # e.g. 10 requests - self._burst_window = burst_window # e.g. per 3600 seconds - - def _get_client_ip(self, request: Request) -> str: - """ - Extract the real client IP. - Checks X-Forwarded-For first (for reverse-proxy / Docker deployments), - falls back to direct connection address. - """ - forwarded_for = request.headers.get("X-Forwarded-For") - if forwarded_for: - # X-Forwarded-For can be a comma-separated list; take the first - return forwarded_for.split(",")[0].strip() - return request.client.host if request.client else "unknown" - - def _make_key(self, ip: str, window_type: str, window_value: int) -> str: - """Build a namespaced Redis key for this IP and time window.""" - return f"rate_limit:scan:{ip}:{window_type}:{window_value}" - - async def check(self, request: Request) -> None: - """ - Check rate limits for the incoming request. - Raises HTTP 429 if either the per-minute or per-hour limit is exceeded. - Does nothing (allows request) if Redis is unavailable. - - Args: - request: The incoming FastAPI request object. - - Raises: - HTTPException: 429 Too Many Requests with Retry-After header. - """ - # If rate limiting is disabled (limit set to 0), pass through immediately - if self._rate_limit == 0: - return - - # If Redis is not configured, fail open with a warning - if self._redis is None: - logger.warning( - "ScanRateLimiter: Redis client is None — rate limiting is DISABLED. " - "Configure REDIS_URL to enable rate limiting." - ) - return - - ip = self._get_client_ip(request) - now = int(time.time()) - - try: - # ── Tier 1: Per-minute limit (burst protection) ────────────────── - minute_window = now // self._rate_window - minute_key = self._make_key(ip, "minute", minute_window) - - pipe = self._redis.pipeline() - pipe.incr(minute_key) - pipe.expire(minute_key, self._rate_window * 2) # 2x TTL for safety - results = await pipe.execute() - minute_count = results[0] - - if minute_count > self._rate_limit: - retry_after = self._rate_window - (now % self._rate_window) - logger.warning( - "Rate limit exceeded (per-minute): ip=%s count=%d limit=%d", - ip, - minute_count, - self._rate_limit, - ) - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail={ - "error": "rate_limit_exceeded", - "message": ( - f"Scan rate limit exceeded: maximum {self._rate_limit} " - f"requests per {self._rate_window} seconds." - ), - "retry_after": retry_after, - }, - headers={"Retry-After": str(retry_after)}, - ) - - # ── Tier 2: Per-hour limit (sustained abuse protection) ────────── - hour_window = now // self._burst_window - hour_key = self._make_key(ip, "hour", hour_window) - - pipe2 = self._redis.pipeline() - pipe2.incr(hour_key) - pipe2.expire(hour_key, self._burst_window * 2) - results2 = await pipe2.execute() - hour_count = results2[0] - - if hour_count > self._burst_limit: - retry_after = self._burst_window - (now % self._burst_window) - logger.warning( - "Rate limit exceeded (per-hour): ip=%s count=%d limit=%d", - ip, - hour_count, - self._burst_limit, - ) - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail={ - "error": "burst_limit_exceeded", - "message": ( - f"Hourly scan limit exceeded: maximum {self._burst_limit} " - f"requests per hour." - ), - "retry_after": retry_after, - }, - headers={"Retry-After": str(retry_after)}, - ) - - except HTTPException: - # Re-raise 429s — don't swallow them in the Redis error handler - raise - except Exception as exc: - # Redis connection error, timeout, etc. — fail open, log, continue - logger.error( - "ScanRateLimiter: Redis error, failing open: %s", exc, exc_info=True - ) - - -def make_scan_rate_limiter( - redis_client: Optional[aioredis.Redis], - rate_limit: int, - rate_window: int, - burst_limit: int, - burst_window: int, -) -> ScanRateLimiter: - """ - Factory function for creating a ScanRateLimiter. - Intended to be called once at app startup and reused across requests. - """ - return ScanRateLimiter( - redis_client=redis_client, - rate_limit=rate_limit, - rate_window=rate_window, - burst_limit=burst_limit, - burst_window=burst_window, - ) diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 22acc6f1c..b8e7c45ea 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -104,7 +104,6 @@ def _json_payload(value: Any, fallback: str) -> str: resolve_client_identity, admin_limiter, scheduler_tick_limiter, ) -from .rate_limiter import check_scan_rate_limit, RateLimitExceeded from .validation import validate_target, validate_task_start_payload, validate_url from .reporting import reporting from .vault import VaultCrypto @@ -196,20 +195,6 @@ async def get_or_set_cached(key: str, builder): return value -from fastapi.responses import JSONResponse -from starlette.status import HTTP_429_TOO_MANY_REQUESTS -from .rate_limiter import RateLimitExceeded - -@router.exception_handler(RateLimitExceeded) -async def rate_limit_exception_handler(request: Request, exc: RateLimitExceeded): - return JSONResponse( - status_code=HTTP_429_TOO_MANY_REQUESTS, - content={ - "error": str(exc.detail) if hasattr(exc, 'detail') else "Too Many Requests", - "retry_after": getattr(exc, 'retry_after', 60), - }, - headers={"Retry-After": str(getattr(exc, 'retry_after', 60))}, - ) async def require_owned_task(db, task_id: str, owner: str, columns: str = "owner_id") -> Dict[str, Any]: @@ -326,7 +311,7 @@ async def get_all_presets(): } -@router.post("/task/start", dependencies=[Depends(task_start_limiter), Depends(check_scan_rate_limit)]) +@router.post("/task/start", dependencies=[Depends(task_start_limiter)]) async def start_task( request: TaskCreateRequest, background_tasks: BackgroundTasks, @@ -486,7 +471,7 @@ async def start_task( "stream_url": f"/api/v1/task/{task_id}/stream" } -@router.post("/task/{task_id}/retry", dependencies=[Depends(task_start_limiter) , Depends(check_scan_rate_limit)]) +@router.post("/task/{task_id}/retry", dependencies=[Depends(task_start_limiter)]) async def retry_task( task_id: str, background_tasks: BackgroundTasks, @@ -1859,7 +1844,7 @@ async def _verify_workflow_owner(db, workflow_id: str, owner: str): return row -@router.post("/workflows/{workflow_id}/run") , dependencies=[Depends(check_scan_rate_limit)] +@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) @@ -2037,7 +2022,7 @@ async def delete_workflow(workflow_id: str, owner: str = Depends(get_current_own return {"workflow_id": workflow_id, "deleted": True} -@router.post("/workflows/scheduler/tick", dependencies=[Depends(scheduler_tick_limiter), Depends(check_scan_rate_limit)]) +@router.post("/workflows/scheduler/tick", dependencies=[Depends(scheduler_tick_limiter)]) async def trigger_workflow_tick(): await scheduler.tick() return {"tick": "ok"} @@ -2087,21 +2072,6 @@ async def create_notification_rule(payload: NotificationRuleCreate, owner: str = raise HTTPException(status_code=500, detail="Failed to create notification rule") return _serialize_notification_rule(row) -@router.get("/rate-limit/status") -async def get_rate_limit_status(request: Request): - """Get current rate limit status for the client.""" - limiter = getattr(request.app.state, 'scan_rate_limiter', None) - if limiter and hasattr(limiter, 'get_status'): - client_id = request.client.host if request.client else "unknown" - status_info = await limiter.get_status(client_id) - return { - "status": "enabled", - "client": client_id, - "remaining": status_info.get("remaining", 0), - "reset_in": status_info.get("reset_in", 0), - } - return {"status": "disabled", "message": "Rate limiting is not enabled"} - async def _verify_notification_rule_owner(db, rule_id: str, owner: str): """Check the notification rule exists and belongs to the caller.""" diff --git a/docs/plugin-validation.md b/docs/plugin-validation.md index b25bb805c..63b5c5ba8 100644 --- a/docs/plugin-validation.md +++ b/docs/plugin-validation.md @@ -154,28 +154,22 @@ Plugins that already define `validation.pattern` (without `validation_type`) con --- -## Common Validation Mistakes & Troubleshooting Matrix - -When writing or updating plugin schemas, authors frequently run into predictable validation edge cases. Use this matrix to identify and resolve common schema parsing issues. - -### Troubleshooting Matrix - -| Issue Symptoms | Root Cause | How to Fix | -| :--- | :--- | :--- | -| **`pattern` regex is being completely ignored** by the frontend form validation loop. | Both `validation_type` and `pattern` are defined in the object. `validation_type` takes strict structural priority over custom regex patterns. | Remove the `validation_type` key if you require custom regex behavior, or adapt your constraint to use an existing preset. | -| **`min` or `max` rules have no effect**; users can input any number they want. | The parent field configuration specifies `"type": "string"`. Range limits only evaluate when `"type": "integer"`. | Update the field configuration line to explicitly use `"type": "integer"`. | -| **Frontend crashes or hangs** when attempting to evaluate a custom input pattern. | The regex string defined inside `pattern` contains an unescaped or invalid syntax constraint. | Validate your regex block independently. Remember that JSON strings require backslashes to be double-escaped (e.g., use `\\d` instead of `\d`). | -| **An optional input field blocks form submission** even when left entirely blank by the user. | The field schema definition contains `"required": true`, overriding the empty string exception check. | Set `"required": false` so the validation engine skips evaluation on blank strings or null elements. | - -### Rule Evaluation Reference - -To keep schemas stable, the validation engine processes properties using this explicit order of execution: -```mermaid -graph TD - A[User Input] --> B{Is field blank?} - B -- Yes --> C{Is field 'required'? : true} - C -- Yes --> D[Blocks Form Submission] - C -- No --> E[Skip Checks: Valid] - B -- No --> F{Has 'validation_type'?} - F -- Yes --> G[Run Named Preset Rules
Ignores 'pattern'] - F -- No --> H[Run Custom 'pattern' Regex] \ No newline at end of file +## 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 +``` + +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/plugin-development-walkthrough.md b/docs/plugins/plugin-development-walkthrough.md deleted file mode 100644 index fb9ea4f41..000000000 --- a/docs/plugins/plugin-development-walkthrough.md +++ /dev/null @@ -1,52 +0,0 @@ -# Plugin Development Walkthrough - -## Introduction - -This guide helps contributors create a new SecuScan plugin. - -## Plugin Structure - -plugins/example_plugin/ -├── metadata.json -└── parser.py - -## Step 1: Create metadata.json - -Example: - -{ - "id": "example_plugin", - "name": "Example Plugin", - "category": "recon", - "safety_level": "safe" -} - -## Step 2: Create parser.py - -Example: - -def parse(output): - return { - "findings": [], - "raw_output": output - } - -## Validation - -python scripts/validate_plugins.py - -python scripts/validate_plugin.py --plugin example_plugin - -## Refresh Checksum - -python scripts/refresh_plugin_checksum.py --plugin example_plugin - -## Common Mistakes - -- Missing metadata fields -- Invalid safety level -- Incorrect parser output - -## Conclusion - -You are now ready to contribute a plugin. \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d3e4a12b..9c3e6412b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' import AppShell from './components/AppShell' import Dashboard from './pages/Dashboard' import Toolkit from './pages/Toolkit' @@ -11,7 +11,6 @@ import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' import Workflows from './pages/Workflows' -import NotFound from './pages/NotFound' import ApiKeySetupScreen from './components/ApiKeySetupScreen' import ErrorBoundary from './components/ErrorBoundary' @@ -19,7 +18,7 @@ import { ThemeProvider } from './components/ThemeContext' import { ToastProvider } from './components/ToastContext' import { I18nProvider } from './components/I18nContext' import { routes } from './routes' -import { AUTH_REQUIRED_EVENT, checkAuthSession } from './api' +import { AUTH_REQUIRED_EVENT, getStoredApiKey } from './api' export function AppRoutes() { return ( @@ -35,21 +34,14 @@ export function AppRoutes() { } /> } /> - } /> + } /> ) } export default function App() { - const [needsKey, setNeedsKey] = useState(true) - const [checkingSession, setCheckingSession] = useState(true) - - useEffect(() => { - checkAuthSession().then((authenticated) => { - setNeedsKey(!authenticated) - setCheckingSession(false) - }) - }, []) + // True when setup is needed: no key stored, or any request got a 401. + const [needsKey, setNeedsKey] = useState(() => !getStoredApiKey()) useEffect(() => { function onAuthRequired() { @@ -59,16 +51,14 @@ export default function App() { return () => window.removeEventListener(AUTH_REQUIRED_EVENT, onAuthRequired) }, []) - if (checkingSession) { - return null - } - return ( {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. setNeedsKey(false)} /> ) : ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c097d728d..388bdba2f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -288,59 +288,26 @@ export interface TaskStartResponse { stream_url: string } -let _apiKey: string | null = null +const API_KEY_STORAGE_KEY = 'secuscan_api_key' export function getStoredApiKey(): string | null { - return _apiKey -} - -export function setStoredApiKey(key: string): void { - _apiKey = key -} - -export function clearStoredApiKey(): void { - _apiKey = null -} - -export async function authenticateWithApiKey(apiKey: string): Promise { - const response = await fetch(`${API_BASE}/auth/session`, { - method: 'POST', - headers: { 'X-Api-Key': apiKey }, - credentials: 'include', - }) - if (!response.ok) { - const body = await response.json().catch(() => ({})) - throw new Error(body?.detail || 'Authentication failed') - } - _apiKey = apiKey -} - -export async function checkAuthSession(): Promise { try { - const response = await fetch(`${API_BASE}/auth/session/check`, { - credentials: 'include', - }) - const data = await response.json() - return !!data.authenticated + return localStorage.getItem(API_KEY_STORAGE_KEY) || null } catch { - return false + return null } } -export async function logoutSession(): Promise { +export function setStoredApiKey(key: string): void { try { - await fetch(`${API_BASE}/auth/session/logout`, { - method: 'POST', - credentials: 'include', - }) + localStorage.setItem(API_KEY_STORAGE_KEY, key) } catch { - // ignore + // ignore storage errors } - _apiKey = null } function getApiKey(): string | null { - return _apiKey + return getStoredApiKey() } /** Fired on the window when any API request receives HTTP 401. */ @@ -360,12 +327,12 @@ async function request(path: string, init?: RequestInit): Promise { ...authHeaders, ...(init?.headers as Record | undefined), }, - credentials: 'include', signal: controller.signal, }) if (response.status === 401) { - _apiKey = null + // 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') } diff --git a/frontend/src/components/ApiKeySetupModal.tsx b/frontend/src/components/ApiKeySetupModal.tsx index c83e9bc86..60ab4c105 100644 --- a/frontend/src/components/ApiKeySetupModal.tsx +++ b/frontend/src/components/ApiKeySetupModal.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { authenticateWithApiKey } from '../api' +import { setStoredApiKey } from '../api' interface Props { onSaved: () => void @@ -10,29 +10,24 @@ interface Props { * * Shown when the app receives HTTP 401 or detects no stored API key. * The operator reads the key from `backend/data/.api_key`, pastes it here, - * and clicks Save. The key is sent to the backend which validates it and - * sets an HttpOnly session cookie; the raw key is never persisted in the - * browser. + * and clicks Save. The key is written only to localStorage (secuscan_api_key); + * it is never sent to any server other than as the X-Api-Key request header. */ export default function ApiKeySetupModal({ onSaved }: Props) { const [key, setKey] = useState('') const [visible, setVisible] = useState(false) const [error, setError] = useState('') - async function handleSave() { + function handleSave() { const trimmed = key.trim() if (!trimmed) { setError('Please enter the API key.') return } - try { - await authenticateWithApiKey(trimmed) - setKey('') - setError('') - onSaved() - } catch (err: any) { - setError(err?.message || 'Authentication failed. Check the API key.') - } + setStoredApiKey(trimmed) + setKey('') + setError('') + onSaved() } return ( @@ -121,8 +116,8 @@ export default function ApiKeySetupModal({ onSaved }: Props) { Save and connect

- The key is sent to the backend which validates it and sets an HttpOnly - session cookie. The raw key is never persisted in the browser. + The key is stored only in your browser's localStorage and sent exclusively + as the X-Api-Key request header — it is never stored server-side.

diff --git a/frontend/src/components/ApiKeySetupScreen.tsx b/frontend/src/components/ApiKeySetupScreen.tsx index be23b37df..955ead324 100644 --- a/frontend/src/components/ApiKeySetupScreen.tsx +++ b/frontend/src/components/ApiKeySetupScreen.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { authenticateWithApiKey } from '../api' +import { setStoredApiKey } from '../api' interface Props { onSaved: () => void @@ -13,27 +13,24 @@ interface Props { * component mounts and no protected API call fires before the key is saved. * * The operator reads the key from the server key file and pastes it here. - * The key is sent to the backend which validates it and sets an HttpOnly - * session cookie; the raw key is never persisted in the browser. + * The key is stored only in localStorage under `secuscan_api_key` and sent + * exclusively as the `X-Api-Key` request header — never logged or stored + * server-side. */ export default function ApiKeySetupScreen({ onSaved }: Props) { const [key, setKey] = useState('') const [error, setError] = useState('') - async function handleSave() { + function handleSave() { const trimmed = key.trim() if (!trimmed) { setError('Please enter the API key.') return } - try { - await authenticateWithApiKey(trimmed) - setKey('') - setError('') - onSaved() - } catch (err: any) { - setError(err?.message || 'Authentication failed. Check the API key.') - } + setStoredApiKey(trimmed) + setKey('') + setError('') + onSaved() } return ( @@ -130,9 +127,10 @@ export default function ApiKeySetupScreen({ onSaved }: Props) { Save and connect

- The key is sent to the backend which validates it and sets an HttpOnly - session cookie. The raw key is never persisted in the browser and is held - only in memory for the duration of the page session. + The key is stored only in your browser's localStorage under{' '} + secuscan_api_key and sent as the{' '} + X-Api-Key header on every API request. It is never transmitted + to any third party or stored on the server beyond the key file.

diff --git a/frontend/src/hooks/useTaskSubscription.ts b/frontend/src/hooks/useTaskSubscription.ts index be2c9c9e4..18652fdca 100644 --- a/frontend/src/hooks/useTaskSubscription.ts +++ b/frontend/src/hooks/useTaskSubscription.ts @@ -34,12 +34,11 @@ export function useTaskSubscription({ const onPhaseRef = useRef(onPhase) const onOutputRef = useRef(onOutput) const esRef = useRef(null) - const pollTimerRef = useRef | null>(null) + const pollIntervalRef = useRef | null>(null) const reconnectAttemptRef = useRef(0) const reconnectTimerRef = useRef | null>(null) const lastStatusRef = useRef(null) const cleanupRef = useRef(false) - const versionRef = useRef(0) onStatusRef.current = onStatus onPhaseRef.current = onPhase @@ -47,14 +46,13 @@ export function useTaskSubscription({ const cleanupAll = useCallback(() => { cleanupRef.current = true - versionRef.current += 1 if (esRef.current) { esRef.current.close() esRef.current = null } - if (pollTimerRef.current) { - clearTimeout(pollTimerRef.current) - pollTimerRef.current = null + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current) + pollIntervalRef.current = null } if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current) @@ -64,16 +62,13 @@ export function useTaskSubscription({ const startPolling = useCallback(() => { if (cleanupRef.current) return - const version = versionRef.current + 1 - versionRef.current = version setIsPolling(true) setIsConnected(false) - - const poll = async () => { - if (cleanupRef.current || versionRef.current !== version) return + pollIntervalRef.current = setInterval(async () => { + if (cleanupRef.current) return try { const data = await getTaskStatus(taskId) as { status?: string } - if (cleanupRef.current || versionRef.current !== version) return + if (cleanupRef.current) return if (data.status && data.status !== lastStatusRef.current) { lastStatusRef.current = data.status onStatusRef.current?.(data.status) @@ -81,22 +76,14 @@ export function useTaskSubscription({ if (data.status && ['completed', 'failed', 'cancelled'].includes(data.status)) { cleanupAll() setIsPolling(false) - return } } catch { } - if (!cleanupRef.current && versionRef.current === version) { - pollTimerRef.current = setTimeout(poll, pollingInterval) - } - } - - poll() + }, pollingInterval) }, [taskId, pollingInterval, cleanupAll]) const connectSSE = useCallback(() => { if (cleanupRef.current) return - const version = versionRef.current + 1 - versionRef.current = version if (esRef.current) { esRef.current.close() esRef.current = null @@ -107,7 +94,7 @@ export function useTaskSubscription({ esRef.current = es es.addEventListener('status', (e: MessageEvent) => { - if (cleanupRef.current || versionRef.current !== version) return + if (cleanupRef.current) return try { const data = JSON.parse(e.data) as { status: string; scan_phase?: string } if (data.scan_phase) { @@ -127,7 +114,7 @@ export function useTaskSubscription({ }) es.addEventListener('phase', (e: MessageEvent) => { - if (cleanupRef.current || versionRef.current !== version) return + if (cleanupRef.current) return try { const data = JSON.parse(e.data) as { scan_phase: string } if (data.scan_phase) { @@ -138,7 +125,7 @@ export function useTaskSubscription({ }) es.addEventListener('output', (e: MessageEvent) => { - if (cleanupRef.current || versionRef.current !== version) return + if (cleanupRef.current) return try { const data = JSON.parse(e.data) as { chunk: string } if (data.chunk) { @@ -149,7 +136,7 @@ export function useTaskSubscription({ }) es.onerror = () => { - if (cleanupRef.current || versionRef.current !== version) return + if (cleanupRef.current) return es.close() esRef.current = null setIsConnected(false) @@ -167,7 +154,7 @@ export function useTaskSubscription({ } es.onopen = () => { - if (cleanupRef.current || versionRef.current !== version) return + if (cleanupRef.current) return reconnectAttemptRef.current = 0 setIsConnected(true) setIsPolling(false) diff --git a/frontend/src/index.css b/frontend/src/index.css index a74e62f3c..31549e3b4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3855,19 +3855,3 @@ button, white-space: nowrap; } } - -/* ============================================ - Light Theme Overrides - ============================================ */ -.theme-light { - --bg-primary: #f8f9fa; - --bg-secondary: #ffffff; - --bg-tertiary: #f1f3f5; - --bg-elevated: #e9ecef; - --accent-silver: #6c757d; - --accent-silver-dim: #adb5bd; - --accent-silver-bright: #212529; - --text-primary: #212529; - --text-secondary: #495057; - --text-muted: #6c757d; -} diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 432ef9cb8..b24d5984f 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -5,7 +5,6 @@ import { getFindings } from '../api' import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' import SavedViewsPanel from '../components/SavedViewsPanel' import { useSavedViews, FilterPreset } from '../hooks/useSavedViews' -import { exportFindingsAsCSV, exportFindingsAsJSON } from '../utils/exportUtils' type RiskFactor = { factor: string @@ -163,10 +162,6 @@ export default function Findings() { const [reviewState, setReviewState] = useState({}) const [copiedFindingId, setCopiedFindingId] = useState(null) - // ── Multi-select export state & handlers ─────────────────────────────────── - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [exportDropdownOpen, setExportDropdownOpen] = useState(false) - // ── Saved views ──────────────────────────────────────────────────────────── const { views, loading: viewsLoading, saveView, deleteView, renameView } = useSavedViews() @@ -328,51 +323,6 @@ export default function Findings() { }) }, [enrichedFindings, filterSeverity, filterTarget, filterScanner, filterAsset, filterKind, filterAnalystStatus, filterValidatedOnly, filterHighConfidence, searchQuery, dateFrom, dateTo]) - // ── Multi-select export state & handlers ─────────────────────────────────── - const visibleIds = useMemo(() => filteredFindings.map((f) => f.id), [filteredFindings]) - const isAllSelected = useMemo(() => { - if (visibleIds.length === 0) return false - return visibleIds.every((id) => selectedIds.has(id)) - }, [visibleIds, selectedIds]) - - const handleSelectAllToggle = () => { - if (isAllSelected) { - setSelectedIds((prev) => { - const next = new Set(prev) - visibleIds.forEach((id) => next.delete(id)) - return next - }) - } else { - setSelectedIds((prev) => { - const next = new Set(prev) - visibleIds.forEach((id) => next.add(id)) - return next - }) - } - } - - const handleCheckboxChange = (id: string, checked: boolean) => { - setSelectedIds((prev) => { - const next = new Set(prev) - if (checked) { - next.add(id) - } else { - next.delete(id) - } - return next - }) - } - - const handleExportCSV = () => { - const selectedFindings = findings.filter((f) => selectedIds.has(f.id)) - exportFindingsAsCSV(selectedFindings) - } - - const handleExportJSON = () => { - const selectedFindings = findings.filter((f) => selectedIds.has(f.id)) - exportFindingsAsJSON(selectedFindings) - } - const sortedFindings = useMemo(() => { const items = [...filteredFindings] switch (sortMode) { @@ -496,7 +446,6 @@ export default function Findings() { setDateFrom('') setDateTo('') setSearchQuery('') - setSelectedIds(new Set()) } function updateFindingStatus(id: string, status: FindingStatus) { @@ -852,78 +801,15 @@ export default function Findings() {

Adjust filters to reopen the queue.

) : ( - <> - {/* Selection & Export Toolbar */} -
-
- - - {selectedIds.size > 0 && ( - - {selectedIds.size} Selected - - )} -
- - {selectedIds.size > 0 && ( -
- - {exportDropdownOpen && ( -
- - -
- )} -
- )} -
- -
+
{/* Virtualizer inner container */}
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'}`} > - {/* Checkbox column */} -
- handleCheckboxChange(finding.id, e.target.checked)} - className="h-4 w-4 accent-[var(--accent-rag-red)] cursor-pointer" - /> -
- - {/* Details button */} - -
+
+ ) })() )} @@ -1067,20 +937,19 @@ export default function Findings() { })}
- {!loading && findings.length < totalItems && ( -
- -
- )} - - )} + )} + {!loading && findings.length < totalItems && ( +
+ +
+ )} {/* ── Detail Panel (unchanged) ── */} diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx deleted file mode 100644 index 1909e3714..000000000 --- a/frontend/src/pages/NotFound.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useEffect } from 'react' -import { Link } from 'react-router-dom' -import { motion } from 'framer-motion' -import { useTheme } from '../components/ThemeContext' -import { routes } from '../routes' - -export default function NotFound() { - const { theme } = useTheme() - const isLight = theme === 'light' - - useEffect(() => { - document.title = '404 - Page Not Found | SecuScan' - }, []) - - return ( -
- - {/* Reuse the existing scanline animation class from index.css */} -
- - {/* Top border decoration */} -
- - {/* Content */} -
- {/* SecuScan Branding inside the 404 card */} -
- SecuScan // Security -
- - {/* Warning Icon Badge */} -
- - gpp_bad - -
- - {/* Error Code & Headings Hierarchy */} -
-

- 404 -

- -

- Page Not Found -

- -

- Perimeter Breach // Mismatch -

-
- - {/* Divider line */} -
- - {/* Explanation Message */} -

- The requested page does not exist or has been relocated outside the mapped perimeter matrix. Verification failed. Access denied or target route is not configured. -

- - {/* Action Button */} - - Return to Dashboard - -
- -
- ) -} diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index a337948c4..8a073f895 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -488,24 +488,7 @@ export default function Reports() { ))} - {filteredReports.length === 0 && reports.length === 0 && ( -
-
- )} - {filteredReports.length === 0 && reports.length > 0 && ( + {filteredReports.length === 0 && (
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 6f4e2a2b9..a0c6d56c4 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -3,14 +3,12 @@ import { motion, AnimatePresence } from 'framer-motion' import { useTheme } from '../components/ThemeContext' import { useToast } from '../components/ToastContext' import { - authenticateWithApiKey, - clearStoredApiKey, createNotificationRule, deleteNotificationRule, getStoredApiKey, listNotificationHistory, listNotificationRules, - logoutSession, + setStoredApiKey, updateNotificationRule, type NotificationChannelType, type NotificationHistoryRow, @@ -207,25 +205,20 @@ export default function Settings() { } } - const handleSaveApiKey = async () => { + const handleSaveApiKey = () => { const trimmed = apiKey.trim() if (!trimmed) { addToast("API key cannot be empty", "error") return } - try { - await authenticateWithApiKey(trimmed) - addToast("API key saved — all future requests will use this key", "success") - } catch (err: any) { - addToast(err?.message || "Authentication failed", "error") - } + setStoredApiKey(trimmed) + addToast("API key saved — all future requests will use this key", "success") } - const handleClearApiKey = async () => { + const handleClearApiKey = () => { if (window.confirm("Clear the stored API key? The UI will return 401 errors until a valid key is configured.")) { setApiKey('') - clearStoredApiKey() - await logoutSession() + setStoredApiKey('') addToast("API key cleared", "info") } } @@ -420,7 +413,7 @@ export default function Settings() {

- Read from backend/data/.api_key after starting the backend. Sent to the backend which sets an HttpOnly session cookie — never persisted in browser storage. + Read from backend/data/.api_key after starting the backend. Stored locally in browser — never sent to any remote server.

diff --git a/frontend/src/pages/Toolkit.tsx b/frontend/src/pages/Toolkit.tsx index 16276ec33..d23ee2b87 100644 --- a/frontend/src/pages/Toolkit.tsx +++ b/frontend/src/pages/Toolkit.tsx @@ -432,9 +432,9 @@ export default function Scanner() {
-

- {tool.name} -

+

+ {tool.name} +

diff --git a/frontend/src/utils/exportUtils.ts b/frontend/src/utils/exportUtils.ts deleted file mode 100644 index 562c99ca8..000000000 --- a/frontend/src/utils/exportUtils.ts +++ /dev/null @@ -1,73 +0,0 @@ -export function escapeCSV(val: any): string { - if (val === null || val === undefined) return '' - const str = String(val) - if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { - return `"${str.replace(/"/g, '""')}"` - } - return str -} - -export function serializeFindingsToCSV(findings: any[]): string { - const headers = [ - 'ID', - 'Title', - 'Severity', - 'Category', - 'Target', - 'Discovered At', - 'CVSS', - 'CVE', - 'Risk Score', - 'Confidence', - 'Validated', - 'Analyst Status', - 'Description', - 'Remediation' - ] - - const rows = findings.map((f) => [ - f.id || '', - f.title || '', - f.severity || '', - f.category || '', - f.target || '', - f.discovered_at || '', - f.cvss !== undefined && f.cvss !== null ? String(f.cvss) : '', - f.cve || '', - f.risk_score !== undefined && f.risk_score !== null ? String(f.risk_score) : '', - f.confidence !== undefined && f.confidence !== null ? String(f.confidence) : '', - f.validated ? 'true' : 'false', - f.analyst_status || '', - f.description || '', - f.remediation || '' - ]) - - return [ - headers.join(','), - ...rows.map((row) => row.map(escapeCSV).join(',')) - ].join('\n') -} - -export function downloadFile(content: string, filename: string, contentType: string): void { - const blob = new Blob([content], { type: contentType }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) -} - -export function exportFindingsAsCSV(findings: any[]): void { - const csvContent = serializeFindingsToCSV(findings) - const dateStr = new Date().toISOString().split('T')[0] - downloadFile(csvContent, `secuscan_findings_${dateStr}.csv`, 'text/csv;charset=utf-8;') -} - -export function exportFindingsAsJSON(findings: any[]): void { - const jsonContent = JSON.stringify(findings, null, 2) - const dateStr = new Date().toISOString().split('T')[0] - downloadFile(jsonContent, `secuscan_findings_${dateStr}.json`, 'application/json') -} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index b5fe353cd..3b803a0d2 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,6 +1,5 @@ /** @type {import('tailwindcss').Config} */ export default { - darkMode: 'class', content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", @@ -8,15 +7,6 @@ export default { theme: { extend: { colors: { - 'primary': 'var(--bg-primary)', - 'secondary': 'var(--bg-secondary)', - 'bg-primary': 'var(--bg-primary)', - 'bg-secondary': 'var(--bg-secondary)', - 'bg-tertiary': 'var(--bg-tertiary)', - 'bg-elevated': 'var(--bg-elevated)', - 'primary-text': 'var(--text-primary)', - 'secondary-text': 'var(--text-secondary)', - 'muted': 'var(--text-muted)', 'charcoal-dark': '#0a0a0c', charcoal: { light: '#1d1d21', diff --git a/frontend/testing/unit/App.auth.gate.test.tsx b/frontend/testing/unit/App.auth.gate.test.tsx index fe427c6b7..316e8444c 100644 --- a/frontend/testing/unit/App.auth.gate.test.tsx +++ b/frontend/testing/unit/App.auth.gate.test.tsx @@ -1,22 +1,23 @@ /** - * App-level first-run auth gate tests. + * App-level first-run auth gate tests (PR #278). * * The core reviewer requirement: once auth is enabled, the app must NOT let * any protected API call fire before the operator has provided the key. * * Covers: - * - No session → setup screen is rendered; no data fetch is called. + * - No key stored → setup screen is rendered; no fetch() is called. * - Saving a valid key → route tree replaces the setup screen. * - Saving an empty key → validation error; still on setup screen. - * - Session already established → app shell renders immediately. + * - Key already stored → app shell renders immediately; no setup screen. * - AUTH_REQUIRED_EVENT fired → setup screen re-appears; app shell hidden. - * - New key saved after 401 → app shell returns; key stored in memory. + * - New key saved after 401 → app shell returns; localStorage updated. */ import React from 'react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// vi.mock calls are hoisted — all factories must be self-contained. vi.mock('react-router-dom', () => ({ BrowserRouter: ({ children }: { children: React.ReactNode }) => React.createElement('div', { 'data-testid': 'router' }, children), @@ -59,71 +60,44 @@ vi.mock('../../src/pages/Workflows', () => ({ default: () => React.createElement('div', { 'data-testid': 'page-workflows' }), })) -vi.mock('../../src/api', async (importOriginal: () => Promise>) => { - const actual = await importOriginal() - return { - ...actual, - checkAuthSession: vi.fn(), - authenticateWithApiKey: vi.fn(), - } -}) - import App from '../../src/App' -import { AUTH_REQUIRED_EVENT, clearStoredApiKey } from '../../src/api' +import { AUTH_REQUIRED_EVENT, setStoredApiKey } from '../../src/api' -let mockCheckAuthSession: import('vitest').Mock<(...args: any[]) => any> -let mockAuthenticateWithApiKey: import('vitest').Mock<(...args: any[]) => any> +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- -beforeEach(async () => { - clearStoredApiKey() +beforeEach(() => { localStorage.clear() vi.unstubAllGlobals() - const api = await import('../../src/api') - mockCheckAuthSession = api.checkAuthSession as import('vitest').Mock - mockAuthenticateWithApiKey = api.authenticateWithApiKey as import('vitest').Mock - mockCheckAuthSession.mockReset() - mockAuthenticateWithApiKey.mockReset() }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() - clearStoredApiKey() localStorage.clear() }) // --------------------------------------------------------------------------- -// First-run: no session +// First-run: no key stored // --------------------------------------------------------------------------- -describe('first-run gate (no session)', () => { - beforeEach(() => { - mockCheckAuthSession.mockResolvedValue(false) - mockAuthenticateWithApiKey.mockResolvedValue(undefined) - }) - - it('renders the setup screen instead of the app routes', async () => { +describe('first-run gate (no key stored)', () => { + it('renders the setup screen instead of the app routes', () => { const { container } = render(React.createElement(App)) - await waitFor(() => - expect(screen.getByRole('main', { name: /api key setup/i })).toBeTruthy() - ) + expect(screen.getByRole('main', { name: /api key setup/i })).toBeTruthy() expect(container.querySelector('[data-testid="app-shell"]')).toBeNull() }) - it('does not call fetch() while the setup screen is showing', async () => { + it('does not call fetch() while the setup screen is showing', () => { const fetchSpy = vi.fn() vi.stubGlobal('fetch', fetchSpy) render(React.createElement(App)) - await waitFor(() => - expect(screen.getByRole('main', { name: /api key setup/i })).toBeTruthy() - ) expect(fetchSpy).not.toHaveBeenCalled() }) it('shows the app shell after the operator saves a valid key', async () => { render(React.createElement(App)) - await waitFor(() => screen.getByLabelText(/Backend API Key/i)) - fireEvent.change(screen.getByLabelText(/Backend API Key/i), { target: { value: 'my-operator-key' }, }) @@ -135,25 +109,20 @@ describe('first-run gate (no session)', () => { expect(screen.getByTestId('app-shell')).toBeTruthy() }) - it('does not write the key to localStorage after save', async () => { + it('persists the key to localStorage after save', async () => { render(React.createElement(App)) - await waitFor(() => screen.getByLabelText(/Backend API Key/i)) - fireEvent.change(screen.getByLabelText(/Backend API Key/i), { target: { value: 'stored-key-abc' }, }) fireEvent.click(screen.getByText(/Save and connect/i)) await waitFor(() => - expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() + expect(localStorage.getItem('secuscan_api_key')).toBe('stored-key-abc') ) - expect(localStorage.getItem('secuscan_api_key')).toBeNull() }) - it('shows a validation error and stays on setup screen for empty key', async () => { + it('shows a validation error and stays on setup screen for empty key', () => { render(React.createElement(App)) - await waitFor(() => screen.getByLabelText(/Backend API Key/i)) - fireEvent.click(screen.getByText(/Save and connect/i)) expect(screen.getByRole('alert')).toBeTruthy() expect(screen.getByRole('main', { name: /api key setup/i })).toBeTruthy() @@ -161,8 +130,6 @@ describe('first-run gate (no session)', () => { it('saves key on Enter keypress in the input', async () => { render(React.createElement(App)) - await waitFor(() => screen.getByLabelText(/Backend API Key/i)) - const input = screen.getByLabelText(/Backend API Key/i) fireEvent.change(input, { target: { value: 'enter-key-test' } }) fireEvent.keyDown(input, { key: 'Enter' }) @@ -170,22 +137,25 @@ describe('first-run gate (no session)', () => { await waitFor(() => expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() ) - expect(mockAuthenticateWithApiKey).toHaveBeenCalledWith('enter-key-test') + expect(localStorage.getItem('secuscan_api_key')).toBe('enter-key-test') }) }) // --------------------------------------------------------------------------- -// Session already established: app renders normally +// Key already stored: app renders normally // --------------------------------------------------------------------------- -describe('session already established', () => { - beforeEach(() => { - mockCheckAuthSession.mockResolvedValue(true) +describe('key already stored', () => { + it('renders the app shell without the setup screen', () => { + setStoredApiKey('pre-seeded-key') + render(React.createElement(App)) + expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() + expect(screen.getByTestId('app-shell')).toBeTruthy() }) - it('renders the app shell without the setup screen', async () => { + it('does not render the setup screen when a whitespace-trimmed key exists', () => { + localStorage.setItem('secuscan_api_key', 'some-valid-key') render(React.createElement(App)) - await waitFor(() => expect(screen.getByTestId('app-shell')).toBeTruthy()) expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() }) }) @@ -195,14 +165,10 @@ describe('session already established', () => { // --------------------------------------------------------------------------- describe('401 re-triggers setup screen', () => { - beforeEach(() => { - mockCheckAuthSession.mockResolvedValue(true) - mockAuthenticateWithApiKey.mockResolvedValue(undefined) - }) - it('shows the setup screen when AUTH_REQUIRED_EVENT fires', async () => { + setStoredApiKey('valid-key') render(React.createElement(App)) - await waitFor(() => expect(screen.getByTestId('app-shell')).toBeTruthy()) + expect(screen.getByTestId('app-shell')).toBeTruthy() act(() => { window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) }) @@ -213,9 +179,8 @@ describe('401 re-triggers setup screen', () => { }) it('hides the setup screen after a new key is saved post-401', async () => { + setStoredApiKey('valid-key') render(React.createElement(App)) - await waitFor(() => expect(screen.getByTestId('app-shell')).toBeTruthy()) - act(() => { window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) }) await waitFor(() => screen.getByRole('main', { name: /api key setup/i })) @@ -228,12 +193,12 @@ describe('401 re-triggers setup screen', () => { expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() ) expect(screen.getByTestId('app-shell')).toBeTruthy() - expect(mockAuthenticateWithApiKey).toHaveBeenCalledWith('new-key-after-401') + expect(localStorage.getItem('secuscan_api_key')).toBe('new-key-after-401') }) it('does not call fetch() after 401 until the new key is saved', async () => { + setStoredApiKey('old-key') render(React.createElement(App)) - await waitFor(() => expect(screen.getByTestId('app-shell')).toBeTruthy()) const fetchSpy = vi.fn() vi.stubGlobal('fetch', fetchSpy) @@ -241,6 +206,7 @@ describe('401 re-triggers setup screen', () => { act(() => { window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) }) await waitFor(() => screen.getByRole('main', { name: /api key setup/i })) + // The app shell is gone — no components that would call fetch are mounted. expect(fetchSpy).not.toHaveBeenCalled() }) }) diff --git a/frontend/testing/unit/AppRoutes.test.tsx b/frontend/testing/unit/AppRoutes.test.tsx index b6e598641..031ac013e 100644 --- a/frontend/testing/unit/AppRoutes.test.tsx +++ b/frontend/testing/unit/AppRoutes.test.tsx @@ -1,7 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, useLocation } from 'react-router-dom' import { AppRoutes } from '../../src/App' -import { ThemeProvider } from '../../src/components/ThemeContext' vi.mock('../../src/api', () => ({ getHealth: vi.fn().mockResolvedValue({ status: 'operational' }), @@ -42,29 +41,24 @@ function PathProbe() { } describe('App route fallback', () => { - it('renders NotFound page for unknown routes', async () => { + it('redirects unknown routes to dashboard', async () => { render( - - - - - - , + + + + , ) await waitFor(() => { - expect(screen.getByTestId('path-probe')).toHaveTextContent('/not-a-real-route') + expect(screen.getByTestId('path-probe')).toHaveTextContent('/') }) - expect(screen.getByText(/Perimeter Breach/i)).toBeInTheDocument() }) it('renders the loaded dashboard summary', async () => { render( - - - - - , + + + , ) expect(await screen.findByText(/Total Findings/i)).toBeInTheDocument() @@ -73,11 +67,9 @@ describe('App route fallback', () => { it('renders the findings workspace', async () => { render( - - - - - , + + + , ) expect(await screen.findByRole('heading', { name: /Findings/i })).toBeInTheDocument() diff --git a/frontend/testing/unit/api.auth.test.ts b/frontend/testing/unit/api.auth.test.ts index ea261d5b7..dc96372f0 100644 --- a/frontend/testing/unit/api.auth.test.ts +++ b/frontend/testing/unit/api.auth.test.ts @@ -1,9 +1,9 @@ /** - * Frontend auth tests. + * Frontend auth tests for PR #278. * * Covers: - * - getStoredApiKey returns null when no key is stored - * - setStoredApiKey stores the key in memory (not localStorage) + * - getStoredApiKey returns null when localStorage is empty + * - setStoredApiKey persists the key to localStorage * - request() includes X-Api-Key header when a key is stored * - request() omits X-Api-Key when no key is stored * - request() fires AUTH_REQUIRED_EVENT on HTTP 401 @@ -16,7 +16,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_REQUIRED_EVENT, - clearStoredApiKey, getStoredApiKey, setStoredApiKey, listPlugins, @@ -40,14 +39,9 @@ function mockResponse(status: number, body: unknown = {}) { describe('getStoredApiKey / setStoredApiKey', () => { beforeEach(() => { - clearStoredApiKey() localStorage.clear() }) - afterEach(() => { - clearStoredApiKey() - }) - it('returns null when no key is stored', () => { expect(getStoredApiKey()).toBeNull() }) @@ -63,9 +57,11 @@ describe('getStoredApiKey / setStoredApiKey', () => { expect(getStoredApiKey()).toBe('new-key') }) - it('does not write the key to localStorage', () => { + it('stores the key only under secuscan_api_key', () => { setStoredApiKey('abc123') - expect(localStorage.getItem('secuscan_api_key')).toBeNull() + expect(localStorage.getItem('secuscan_api_key')).toBe('abc123') + // No other localStorage entry should be written by setStoredApiKey. + expect(localStorage.length).toBe(1) }) }) @@ -76,7 +72,6 @@ describe('getStoredApiKey / setStoredApiKey', () => { describe('request() X-Api-Key header', () => { afterEach(() => { vi.unstubAllGlobals() - clearStoredApiKey() localStorage.clear() }) @@ -112,7 +107,6 @@ describe('request() X-Api-Key header', () => { describe('request() 401 handling', () => { afterEach(() => { vi.unstubAllGlobals() - clearStoredApiKey() localStorage.clear() }) @@ -209,7 +203,6 @@ vi.spyOn(window, 'setTimeout').mockImplementation(() => timeoutId) describe('request() successful authenticated request', () => { afterEach(() => { vi.unstubAllGlobals() - clearStoredApiKey() localStorage.clear() }) @@ -231,7 +224,6 @@ describe('API key is never logged', () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() - clearStoredApiKey() localStorage.clear() }) diff --git a/frontend/testing/unit/components/ThemeToggle.test.tsx b/frontend/testing/unit/components/ThemeToggle.test.tsx index c8c2bb8b2..e678b92d7 100644 --- a/frontend/testing/unit/components/ThemeToggle.test.tsx +++ b/frontend/testing/unit/components/ThemeToggle.test.tsx @@ -55,34 +55,6 @@ describe('ThemeToggle', () => { expect(button).toHaveAttribute('aria-pressed', 'true') }) - it('applies the dark class to document root and removes theme-light', async () => { - localStorage.setItem(STORAGE_KEY, 'light') - document.documentElement.classList.remove('dark') - document.documentElement.classList.add('theme-light') - const user = userEvent.setup() - renderWithTheme() - - const button = screen.getByRole('button') - await user.click(button) - - expect(document.documentElement.classList.contains('dark')).toBe(true) - expect(document.documentElement.classList.contains('theme-light')).toBe(false) - }) - - it('applies the theme-light class to document root and removes dark', async () => { - localStorage.setItem(STORAGE_KEY, 'dark') - document.documentElement.classList.add('dark') - document.documentElement.classList.remove('theme-light') - const user = userEvent.setup() - renderWithTheme() - - const button = screen.getByRole('button') - await user.click(button) - - expect(document.documentElement.classList.contains('theme-light')).toBe(true) - expect(document.documentElement.classList.contains('dark')).toBe(false) - }) - it('aria-label reflects the target theme, not the current one', () => { localStorage.setItem(STORAGE_KEY, 'dark') renderWithTheme() diff --git a/frontend/testing/unit/hooks/useTaskSubscription.test.ts b/frontend/testing/unit/hooks/useTaskSubscription.test.ts index 81103b172..1fe0fc5ae 100644 --- a/frontend/testing/unit/hooks/useTaskSubscription.test.ts +++ b/frontend/testing/unit/hooks/useTaskSubscription.test.ts @@ -136,13 +136,12 @@ describe('useTaskSubscription', () => { const es = getES()! await act(() => { es.triggerError() }) - // startPolling calls poll() immediately (chained setTimeout), so one call - // happens synchronously before the first interval elapses. + await tickTime(50) - expect(getTaskStatus).toHaveBeenCalledTimes(2) // initial (direct) + first timer + expect(getTaskStatus).toHaveBeenCalledTimes(1) await tickTime(50) - expect(getTaskStatus).toHaveBeenCalledTimes(3) // initial + first + second timer + expect(getTaskStatus).toHaveBeenCalledTimes(2) }) it('stops polling on terminal status', async () => { diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx index c7787ade4..5a23d8c85 100644 --- a/frontend/testing/unit/pages/Findings.test.tsx +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -10,13 +10,6 @@ vi.mock('../../../src/api', () => ({ getFindings: vi.fn(), })) -vi.mock('../../../src/utils/exportUtils', () => ({ - exportFindingsAsCSV: vi.fn(), - exportFindingsAsJSON: vi.fn(), -})) - -import { exportFindingsAsCSV, exportFindingsAsJSON } from '../../../src/utils/exportUtils' - vi.mock('../../../src/utils/date', async (importOriginal: any) => { const actual = await importOriginal() as typeof import('../../../src/utils/date') return { @@ -266,75 +259,4 @@ describe('Findings — virtualized list', () => { const suppressedChips = screen.queryAllByText('suppressed') expect(suppressedChips.length).toBeGreaterThan(0) }) - - it('individual checkbox click selects finding for export but doesn\'t change selected finding details', async () => { - const findings = [ - makeFinding({ id: 'f1', title: 'SQL Injection', severity: 'critical' }), - makeFinding({ id: 'f2', title: 'CSRF Vulnerability', severity: 'high' }), - ] - vi.mocked(getFindings).mockResolvedValue({ findings }) - - render() - await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - - const checkboxF2 = screen.getByLabelText('Select CSRF Vulnerability') - expect(checkboxF2).not.toBeChecked() - - await userEvent.click(checkboxF2) - expect(checkboxF2).toBeChecked() - - expect(screen.getByRole('button', { name: /Bulk Export/i })).toBeInTheDocument() - - expect(screen.getByRole('heading', { name: /SQL Injection/i, level: 2 })).toBeInTheDocument() - }) - - it('select all checkbox toggles selection for all visible findings', async () => { - const findings = [ - makeFinding({ id: 'f1', title: 'SQL Injection', severity: 'critical' }), - makeFinding({ id: 'f2', title: 'CSRF Vulnerability', severity: 'high' }), - ] - vi.mocked(getFindings).mockResolvedValue({ findings }) - - render() - await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - - const selectAllCheckbox = screen.getByLabelText(/Select All Visible/i) - await userEvent.click(selectAllCheckbox) - - expect(screen.getByLabelText('Select SQL Injection')).toBeChecked() - expect(screen.getByLabelText('Select CSRF Vulnerability')).toBeChecked() - - await userEvent.click(selectAllCheckbox) - expect(screen.getByLabelText('Select SQL Injection')).not.toBeChecked() - expect(screen.getByLabelText('Select CSRF Vulnerability')).not.toBeChecked() - }) - - it('trigger CSV and JSON bulk export calls utility function', async () => { - const findings = [ - makeFinding({ id: 'f1', title: 'SQL Injection', severity: 'critical' }), - ] - vi.mocked(getFindings).mockResolvedValue({ findings }) - - render() - await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - - await userEvent.click(screen.getByLabelText('Select SQL Injection')) - - const bulkExportBtn = screen.getByRole('button', { name: /Bulk Export/i }) - await userEvent.click(bulkExportBtn) - - const csvExportBtn = screen.getByRole('button', { name: /Export as CSV/i }) - const jsonExportBtn = screen.getByRole('button', { name: /Export as JSON/i }) - - expect(csvExportBtn).toBeInTheDocument() - expect(jsonExportBtn).toBeInTheDocument() - - await userEvent.click(csvExportBtn) - expect(exportFindingsAsCSV).toHaveBeenCalled() - - await userEvent.click(bulkExportBtn) - const newJsonExportBtn = await screen.findByRole('button', { name: /Export as JSON/i }) - await userEvent.click(newJsonExportBtn) - expect(exportFindingsAsJSON).toHaveBeenCalled() - }) }) diff --git a/frontend/testing/unit/pages/Reports.onboarding.test.tsx b/frontend/testing/unit/pages/Reports.onboarding.test.tsx deleted file mode 100644 index 7bdd0e1ac..000000000 --- a/frontend/testing/unit/pages/Reports.onboarding.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' -import Reports from '../../../src/pages/Reports' -import { getReports, getDashboardSummary } from '../../../src/api' - -vi.mock('../../../src/api', () => ({ - getReports: vi.fn(), - getDashboardSummary: vi.fn(), - API_BASE: 'http://127.0.0.1:8000', -})) - -const readyReport = { - id: 'report-1', - task_id: 'task-abc-123', - name: 'Security Scan — example.com', - type: 'technical', - generated_at: '2026-05-14T10:00:00Z', - status: 'ready', - findings: 7, - assets: 3, - pages: 12, -} - -const emptySummary = { - total_findings: 0, - total_assets: 0, - critical_findings: 0, - high_findings: 0, - total_attack_surface: 0, -} - -function renderReports() { - return render( - - - , - ) -} - -beforeEach(() => { - vi.mocked(getDashboardSummary).mockResolvedValue(emptySummary) -}) - -describe('Reports — onboarding empty state', () => { - it('shows the onboarding message when there are zero reports', async () => { - vi.mocked(getReports).mockResolvedValue({ reports: [] }) - renderReports() - - expect(await screen.findByText(/No Briefings Yet/i)).toBeInTheDocument() - expect(screen.getByText(/Run a scan from the Toolkit/i)).toBeInTheDocument() - }) - - it('shows a call-to-action link pointing to the toolkit route', async () => { - vi.mocked(getReports).mockResolvedValue({ reports: [] }) - renderReports() - - const cta = await screen.findByRole('link', { name: /launch_first_scan/i }) - expect(cta).toHaveAttribute('href', '/toolkit') - }) - - it('does not show the onboarding message when reports exist but filters hide them all', async () => { - const user = userEvent.setup() - vi.mocked(getReports).mockResolvedValue({ reports: [readyReport] }) - renderReports() - - await screen.findByText(/Security Scan — example.com/i) - await user.click(screen.getByRole('button', { name: /executive briefings/i })) - - expect(screen.queryByText(/No Briefings Yet/i)).not.toBeInTheDocument() - expect(await screen.findByText(/Archive Isolated/i)).toBeInTheDocument() - }) - - it('does not show the onboarding empty state once reports are loaded', async () => { - vi.mocked(getReports).mockResolvedValue({ reports: [readyReport] }) - renderReports() - - await screen.findByText(/Security Scan — example.com/i) - - expect(screen.queryByText(/No Briefings Yet/i)).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: /launch_first_scan/i })).not.toBeInTheDocument() - }) -}) \ No newline at end of file diff --git a/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx b/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx index 3e3612e73..3dae06386 100644 --- a/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx +++ b/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx @@ -51,7 +51,7 @@ describe('Reports — preferred export format', () => { const user = userEvent.setup() renderReports() - await user.click(await screen.findByRole('button', { name: /^pdf$/ })) + await user.click(await screen.findByRole('button', { name: /^pdf$/i })) expect(localStorage.getItem('secuscan:preferred-export-format')).toBe('pdf') }) @@ -60,7 +60,7 @@ describe('Reports — preferred export format', () => { const user = userEvent.setup() renderReports() - await user.click(await screen.findByRole('button', { name: /^pdf$/ })) + await user.click(await screen.findByRole('button', { name: /^pdf$/i })) await user.click(screen.getByRole('button', { name: /^csv$/i })) expect(localStorage.getItem('secuscan:preferred-export-format')).toBe('csv') @@ -81,7 +81,7 @@ describe('Reports — preferred export format', () => { await screen.findByRole('button', { name: /^csv$/i }) - const buttons = screen.getAllByRole('button', { name: /^(pdf|html|csv)$/ }) + const buttons = screen.getAllByRole('button', { name: /^(pdf|html|csv)$/i }) expect(buttons[0].textContent?.toLowerCase()).toBe('csv') }) diff --git a/frontend/testing/unit/pages/Reports.test.tsx b/frontend/testing/unit/pages/Reports.test.tsx index ea2581812..3b3ec2303 100644 --- a/frontend/testing/unit/pages/Reports.test.tsx +++ b/frontend/testing/unit/pages/Reports.test.tsx @@ -103,10 +103,9 @@ describe('Reports — empty state', () => { vi.mocked(getDashboardSummary).mockResolvedValue(emptySummary) }) - it('shows onboarding empty state when there are no reports at all', async () => { + it('shows Archive Isolated when there are no reports at all', async () => { renderReports() - expect(await screen.findByText(/No Briefings Yet/i)).toBeInTheDocument() - expect(screen.getByRole('link', { name: /launch_first_scan/i })).toBeInTheDocument() + expect(await screen.findByText(/Archive Isolated/i)).toBeInTheDocument() }) it('shows Archive Isolated when filter returns no matching reports', async () => { @@ -130,15 +129,15 @@ describe('Reports — export buttons on a ready report', () => { it('shows PDF, HTML and CSV buttons for a ready report', async () => { renderReports() - expect(await screen.findByRole('button', { name: /^pdf$/ })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: /^pdf$/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^html$/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^csv$/i })).toBeInTheDocument() }) it('export buttons are enabled for a ready report', async () => { renderReports() - await screen.findByRole('button', { name: /^pdf$/ }) - expect(screen.getByRole('button', { name: /^pdf$/ })).not.toBeDisabled() + await screen.findByRole('button', { name: /^pdf$/i }) + expect(screen.getByRole('button', { name: /^pdf$/i })).not.toBeDisabled() expect(screen.getByRole('button', { name: /^html$/i })).not.toBeDisabled() expect(screen.getByRole('button', { name: /^csv$/i })).not.toBeDisabled() }) @@ -146,7 +145,7 @@ describe('Reports — export buttons on a ready report', () => { it('clicking PDF opens the correct backend URL', async () => { const user = userEvent.setup() renderReports() - await user.click(await screen.findByRole('button', { name: /^pdf$/ })) + await user.click(await screen.findByRole('button', { name: /^pdf$/i })) expect(openSpy).toHaveBeenCalledWith( expect.stringContaining('/task/' + readyReport.task_id + '/report/pdf'), '_blank') }) @@ -170,7 +169,7 @@ describe('Reports — export buttons on a ready report', () => { it('does not use the old placeholder latest-report route', async () => { const user = userEvent.setup() renderReports() - await user.click(await screen.findByRole('button', { name: /^pdf$/ })) + await user.click(await screen.findByRole('button', { name: /^pdf$/i })) expect(openSpy).not.toHaveBeenCalledWith(expect.stringContaining('latest'), expect.anything()) }) }) @@ -219,8 +218,8 @@ describe('Reports — export buttons on a generating report', () => { it('export buttons are disabled when report is generating', async () => { renderReports() - await screen.findByRole('button', { name: /^pdf$/ }) - expect(screen.getByRole('button', { name: /^pdf$/ })).toBeDisabled() + await screen.findByRole('button', { name: /^pdf$/i }) + expect(screen.getByRole('button', { name: /^pdf$/i })).toBeDisabled() expect(screen.getByRole('button', { name: /^html$/i })).toBeDisabled() expect(screen.getByRole('button', { name: /^csv$/i })).toBeDisabled() }) @@ -228,8 +227,8 @@ describe('Reports — export buttons on a generating report', () => { it('clicking a disabled button does not open any URL', async () => { const user = userEvent.setup() renderReports() - await screen.findByRole('button', { name: /^pdf$/ }) - await user.click(screen.getByRole('button', { name: /^pdf$/ })) + await screen.findByRole('button', { name: /^pdf$/i }) + await user.click(screen.getByRole('button', { name: /^pdf$/i })) expect(openSpy).not.toHaveBeenCalled() }) }) @@ -243,8 +242,8 @@ describe('Reports — export buttons on a failed report', () => { it('export buttons are enabled for a failed report since backend supports it', async () => { renderReports() - await screen.findByRole('button', { name: /^pdf$/ }) - expect(screen.getByRole('button', { name: /^pdf$/ })).not.toBeDisabled() + await screen.findByRole('button', { name: /^pdf$/i }) + expect(screen.getByRole('button', { name: /^pdf$/i })).not.toBeDisabled() expect(screen.getByRole('button', { name: /^html$/i })).not.toBeDisabled() expect(screen.getByRole('button', { name: /^csv$/i })).not.toBeDisabled() }) diff --git a/frontend/testing/unit/pages/Scans.test.tsx b/frontend/testing/unit/pages/Scans.test.tsx index ca7d083c0..994f36581 100644 --- a/frontend/testing/unit/pages/Scans.test.tsx +++ b/frontend/testing/unit/pages/Scans.test.tsx @@ -12,7 +12,6 @@ vi.mock('../../../src/api', () => ({ deleteTask: vi.fn().mockResolvedValue({}), clearAllTasks: vi.fn().mockResolvedValue({}), bulkDeleteTasks: vi.fn().mockResolvedValue({}), - startTask: vi.fn().mockResolvedValue({ task_id: 'new-task-123' }), })) vi.mock('../../../src/routes', () => ({ @@ -214,28 +213,4 @@ describe('Scans — task list', () => { await waitFor(() => expect(screen.getByText('Delete Scan Record')).toBeInTheDocument()) }) - - it('renders quick re-run button for completed tasks and triggers handleRescan', async () => { - const tasks = [makeTask({ task_id: 'task-123', status: 'completed', tool: 'nmap' })] - mockFetch(tasks) - renderScans() - - await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) - - const rerunBtn = screen.getByRole('button', { name: /Re-run nmap scan/i }) - expect(rerunBtn).toBeInTheDocument() - - const { startTask } = await import('../../../src/api') - await userEvent.click(rerunBtn) - - await waitFor(() => { - expect(startTask).toHaveBeenCalledWith( - 'nmap', - expect.any(Object), - true, - undefined, - undefined - ) - }) - }) }) diff --git a/frontend/testing/unit/utils/exportUtils.test.ts b/frontend/testing/unit/utils/exportUtils.test.ts deleted file mode 100644 index 3a2eae1bf..000000000 --- a/frontend/testing/unit/utils/exportUtils.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, test, expect } from "vitest"; -import { escapeCSV, serializeFindingsToCSV } from "../../../src/utils/exportUtils"; - -describe("exportUtils utility", () => { - test("escapeCSV handles standard inputs", () => { - expect(escapeCSV("hello")).toBe("hello"); - expect(escapeCSV(123)).toBe("123"); - expect(escapeCSV(null)).toBe(""); - expect(escapeCSV(undefined)).toBe(""); - }); - - test("escapeCSV escapes quotes, commas, and newlines", () => { - expect(escapeCSV('hello, world')).toBe('"hello, world"'); - expect(escapeCSV('hello "world"')).toBe('"hello ""world"""'); - expect(escapeCSV('hello\nworld')).toBe('"hello\nworld"'); - }); - - test("serializeFindingsToCSV generates correct headers and mapped rows", () => { - const sampleFindings = [ - { - id: "f-1", - title: "SQL Injection", - severity: "critical", - category: "Database", - target: "http://target1.local", - discovered_at: "2026-05-12T10:30:00Z", - cvss: 9.8, - cve: "CVE-2026-1234", - risk_score: 9.5, - confidence: 0.9, - validated: true, - analyst_status: "confirmed", - description: "An injection vulnerability in input parameter.", - remediation: "Use parameterized queries." - }, - { - id: "f-2", - title: "Information Disclosure, Version Leak", - severity: "info", - category: "Information", - target: "http://target2.local", - discovered_at: "2026-05-12T10:35:00Z", - cvss: null, - cve: undefined, - risk_score: 1.0, - confidence: 1.0, - validated: false, - analyst_status: "new", - description: "Version string \"1.2.3\" disclosed.", - remediation: "Disable version banners." - } - ]; - - const csvContent = serializeFindingsToCSV(sampleFindings); - - // Header check - expect(csvContent).toContain("ID,Title,Severity,Category,Target,Discovered At,CVSS,CVE,Risk Score,Confidence,Validated,Analyst Status,Description,Remediation"); - - // Row checks - expect(csvContent).toContain("f-1,SQL Injection,critical,Database,http://target1.local,2026-05-12T10:30:00Z,9.8,CVE-2026-1234,9.5,0.9,true,confirmed,An injection vulnerability in input parameter.,Use parameterized queries."); - - // Check comma escaping in title, quote escaping in description - expect(csvContent).toContain('"Information Disclosure, Version Leak"'); - expect(csvContent).toContain('"Version string ""1.2.3"" disclosed."'); - }); -}); diff --git a/testing/backend/integration/test_webhook_egress_policy.py b/testing/backend/integration/test_webhook_egress_policy.py deleted file mode 100644 index 1be594fd3..000000000 --- a/testing/backend/integration/test_webhook_egress_policy.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -Regression guard: webhook delivery must respect network egress controls. - -Asserts the *real* SSRF protection path in send_webhook — DNS resolution -plus validation of every resolved IP against -settings.notification_blocked_ip_ranges — actually blocks loopback and -link-local/metadata destinations before any HTTP request is attempted. -No httpx mocking for the blocking assertions: the policy check runs -before httpx is ever touched, so these tests exercise the real code path. -""" -from __future__ import annotations - -import uuid -from unittest.mock import AsyncMock, patch - -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 NotificationDeliveryStatus -from backend.secuscan.notification_service import ( - deliver_via_rule, - send_webhook, -) - - -@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") -> 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', 'desc', 'fix') - """, - (finding_id, task_id, severity), - ) - return task_id, finding_id - - -async def _seed_rule( - db, - *, - target: str = "https://example.com/hook", - severity_threshold: str = "high", - 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 (?, 'Egress test rule', ?, 'webhook', ?, ?) - """, - (rule_id, severity_threshold, target, is_active), - ) - return rule_id - - -# --------------------------------------------------------------------------- -# Real egress policy path: no httpx mocking, asserts the actual IP-range -# check against settings.notification_blocked_ip_ranges. -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_send_webhook_blocks_loopback_via_real_dns_resolution(): - """ - 'localhost' resolves to 127.0.0.1, which is in the default - notification_blocked_ip_ranges (127.0.0.0/8). send_webhook must reject - it during DNS/IP validation, before httpx is ever invoked. - """ - ok, error = await send_webhook("http://localhost:9/hook", {"event": "test"}) - - assert ok is False - assert error is not None - assert "blocked" in error.lower() or "resolve" in error.lower() - - -@pytest.mark.asyncio -async def test_send_webhook_blocks_literal_loopback_ip(): - """ - A literal loopback IP in the URL must also be blocked — confirms the - check applies to the resolved/parsed IP, not just hostnames that - require a DNS lookup. - """ - ok, error = await send_webhook("http://127.0.0.1:9/hook", {"event": "test"}) - - assert ok is False - assert error is not None - assert "blocked" in error.lower() - assert "127.0.0" in error - - -@pytest.mark.asyncio -async def test_send_webhook_blocks_link_local_metadata_address(): - """ - The cloud metadata endpoint 169.254.169.254 is explicitly listed in - notification_blocked_ip_ranges. This guards against SSRF attacks that - try to exfiltrate cloud instance credentials via a webhook rule. - """ - ok, error = await send_webhook("http://169.254.169.254/hook", {"event": "test"}) - - assert ok is False - assert error is not None - assert "blocked" in error.lower() - - -@pytest.mark.asyncio -async def test_send_webhook_rejects_url_with_no_hostname(): - """A malformed webhook URL with no hostname fails cleanly, pre-DNS.""" - ok, error = await send_webhook("not-a-url", {"event": "test"}) - - assert ok is False - assert "hostname" in error.lower() - - -# --------------------------------------------------------------------------- -# deliver_via_rule integration: the real block surfaces as a FAILED history -# row with an actionable message, end to end, no mocking of send_webhook. -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_deliver_via_rule_records_failure_for_loopback_target(test_db): - """ - End-to-end: a rule configured with a loopback target must fail via the - real SSRF check inside send_webhook, and that failure must be recorded - in notification_history with the blocking reason — no send_webhook - mocking, so this proves the policy check is actually wired in. - """ - _, finding_id = await _seed_finding(test_db) - rule_id = await _seed_rule(test_db, target="http://127.0.0.1:9/hook") - - 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.status == NotificationDeliveryStatus.FAILED - assert result.skipped is False - assert result.error_message is not None - assert "blocked" in result.error_message.lower() - - 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 "blocked" in row["error_message"].lower() - - -@pytest.mark.asyncio -async def test_egress_block_does_not_mark_finding_as_delivered(test_db): - """ - A blocked delivery must not be treated as a successful delivery — - was_already_delivered must stay False so a corrected rule can retry. - """ - from backend.secuscan.notification_service import was_already_delivered - - _, finding_id = await _seed_finding(test_db) - rule_id = await _seed_rule(test_db, target="http://127.0.0.1:9/hook") - - 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,)) - - await deliver_via_rule(test_db, rule, finding) - - assert await was_already_delivered(test_db, rule_id, finding_id) is False - - -# --------------------------------------------------------------------------- -# Legitimate target still works: real network call is mocked at the httpx -# layer only (after IP validation has already passed), proving the policy -# check doesn't false-positive on allowed destinations. -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_send_webhook_allows_non_blocked_target(): - """ - A target that resolves to a public, non-blocked IP must pass the - egress check and reach the HTTP layer, where we mock only the - final response — proving the policy check doesn't over-block. - """ - with patch( - "backend.secuscan.notification_service.socket.getaddrinfo", - return_value=[(2, 1, 6, "", ("93.184.216.34", 443))], - ): - mock_response = AsyncMock() - mock_response.status_code = 200 - with patch( - "backend.secuscan.notification_service.httpx.AsyncClient", - autospec=True, - ) as mock_client_cls: - mock_client = AsyncMock() - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - mock_client.post = AsyncMock(return_value=mock_response) - - ok, error = await send_webhook("https://example.com/hook", {"event": "test"}) - - assert ok is True - assert error is None \ No newline at end of file diff --git a/testing/backend/test_rate_limiter.py b/testing/backend/test_rate_limiter.py deleted file mode 100644 index a3a25a3de..000000000 --- a/testing/backend/test_rate_limiter.py +++ /dev/null @@ -1,293 +0,0 @@ -""" -testing/backend/test_rate_limiter.py - -Tests for backend/secuscan/rate_limiter.py - -Run with: ./testing/test_python.sh -or: pytest testing/backend/test_rate_limiter.py -v -""" - -import asyncio -import time -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastapi import HTTPException -from fastapi.testclient import TestClient - -from backend.secuscan.rate_limiter import ScanRateLimiter, make_scan_rate_limiter - - -# ─── Helpers ────────────────────────────────────────────────────────────────── - -def _make_mock_request(ip: str = "127.0.0.1") -> MagicMock: - """Build a minimal mock FastAPI Request with a controllable client IP.""" - request = MagicMock() - request.client = MagicMock() - request.client.host = ip - request.headers = {} # No X-Forwarded-For by default - return request - - -def _make_mock_request_forwarded(ip: str) -> MagicMock: - """Build a mock request with X-Forwarded-For header.""" - request = MagicMock() - request.client = MagicMock() - request.client.host = "10.0.0.1" # internal proxy IP - request.headers = {"X-Forwarded-For": ip} - return request - - -async def _make_redis_pipe_side_effect(count: int): - """Helper: returns a pipeline mock that produces the given count on execute().""" - pipe = AsyncMock() - pipe.incr = AsyncMock() - pipe.expire = AsyncMock() - pipe.execute = AsyncMock(return_value=[count, True]) - return pipe - - -# ─── Unit Tests: ScanRateLimiter ────────────────────────────────────────────── - -class TestScanRateLimiterDisabled: - """Rate limiting should be a no-op when rate_limit=0.""" - - @pytest.mark.asyncio - async def test_disabled_when_limit_zero(self): - limiter = ScanRateLimiter( - redis_client=None, - rate_limit=0, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - request = _make_mock_request() - # Must not raise anything - await limiter.check(request) - - @pytest.mark.asyncio - async def test_disabled_does_not_touch_redis(self): - mock_redis = AsyncMock() - limiter = ScanRateLimiter( - redis_client=mock_redis, - rate_limit=0, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - request = _make_mock_request() - await limiter.check(request) - # Redis pipeline should never be called - mock_redis.pipeline.assert_not_called() - - -class TestScanRateLimiterNoRedis: - """Should fail open when Redis is None.""" - - @pytest.mark.asyncio - async def test_fails_open_when_redis_none(self): - limiter = ScanRateLimiter( - redis_client=None, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - request = _make_mock_request() - # Must not raise — fail open - await limiter.check(request) - - -class TestScanRateLimiterMinuteWindow: - """Per-minute rate limit enforcement.""" - - @pytest.mark.asyncio - async def test_allows_request_under_limit(self): - mock_redis = AsyncMock() - # Simulate count=3, limit=5 → allowed - pipe = AsyncMock() - pipe.execute = AsyncMock(return_value=[3, True]) - mock_redis.pipeline = MagicMock(return_value=pipe) - - limiter = ScanRateLimiter( - redis_client=mock_redis, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - request = _make_mock_request() - # Must not raise - await limiter.check(request) - - @pytest.mark.asyncio - async def test_rejects_request_over_minute_limit(self): - mock_redis = AsyncMock() - # Simulate count=6, limit=5 → rejected - pipe = AsyncMock() - pipe.execute = AsyncMock(return_value=[6, True]) - mock_redis.pipeline = MagicMock(return_value=pipe) - - limiter = ScanRateLimiter( - redis_client=mock_redis, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - request = _make_mock_request() - - with pytest.raises(HTTPException) as exc_info: - await limiter.check(request) - - assert exc_info.value.status_code == 429 - assert "Retry-After" in exc_info.value.headers - assert exc_info.value.detail["error"] == "rate_limit_exceeded" - - @pytest.mark.asyncio - async def test_rejects_request_over_burst_limit(self): - mock_redis = AsyncMock() - call_count = 0 - - def make_pipe(): - nonlocal call_count - pipe = AsyncMock() - if call_count == 0: - # First pipeline call: minute window, count=3 (under minute limit) - pipe.execute = AsyncMock(return_value=[3, True]) - else: - # Second pipeline call: hour window, count=11 (over burst limit) - pipe.execute = AsyncMock(return_value=[11, True]) - call_count += 1 - return pipe - - mock_redis.pipeline = MagicMock(side_effect=make_pipe) - - limiter = ScanRateLimiter( - redis_client=mock_redis, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - request = _make_mock_request() - - with pytest.raises(HTTPException) as exc_info: - await limiter.check(request) - - assert exc_info.value.status_code == 429 - assert exc_info.value.detail["error"] == "burst_limit_exceeded" - - -class TestScanRateLimiterIPExtraction: - """IP extraction from headers.""" - - @pytest.mark.asyncio - async def test_uses_direct_ip_when_no_forwarded_header(self): - mock_redis = AsyncMock() - pipe = AsyncMock() - pipe.execute = AsyncMock(return_value=[1, True]) - mock_redis.pipeline = MagicMock(return_value=pipe) - - limiter = ScanRateLimiter( - redis_client=mock_redis, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - request = _make_mock_request(ip="192.168.1.1") - await limiter.check(request) - - # Redis key should contain the direct IP - calls = str(mock_redis.pipeline.call_args_list) - incr_calls = str(pipe.incr.call_args_list) - assert "192.168.1.1" in incr_calls - - @pytest.mark.asyncio - async def test_uses_first_ip_from_forwarded_for_header(self): - mock_redis = AsyncMock() - pipe = AsyncMock() - pipe.execute = AsyncMock(return_value=[1, True]) - mock_redis.pipeline = MagicMock(return_value=pipe) - - limiter = ScanRateLimiter( - redis_client=mock_redis, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - # Simulate multi-hop X-Forwarded-For - request = _make_mock_request_forwarded("203.0.113.5, 10.0.0.1, 172.16.0.1") - await limiter.check(request) - - incr_calls = str(pipe.incr.call_args_list) - assert "203.0.113.5" in incr_calls - - -class TestScanRateLimiterRedisError: - """Should fail open on Redis errors.""" - - @pytest.mark.asyncio - async def test_fails_open_on_redis_connection_error(self): - import redis.asyncio as aioredis - - mock_redis = AsyncMock() - mock_redis.pipeline = MagicMock(side_effect=aioredis.ConnectionError("down")) - - limiter = ScanRateLimiter( - redis_client=mock_redis, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - request = _make_mock_request() - # Must not raise — fail open - await limiter.check(request) - - @pytest.mark.asyncio - async def test_fails_open_on_redis_timeout(self): - import redis.asyncio as aioredis - - mock_redis = AsyncMock() - mock_redis.pipeline = MagicMock(side_effect=aioredis.TimeoutError("timeout")) - - limiter = ScanRateLimiter( - redis_client=mock_redis, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - request = _make_mock_request() - await limiter.check(request) - - -class TestMakeScanRateLimiter: - """Factory function tests.""" - - def test_factory_creates_limiter_with_correct_settings(self): - limiter = make_scan_rate_limiter( - redis_client=None, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - assert isinstance(limiter, ScanRateLimiter) - assert limiter._rate_limit == 5 - assert limiter._rate_window == 60 - assert limiter._burst_limit == 10 - assert limiter._burst_window == 3600 - - def test_factory_accepts_none_redis(self): - limiter = make_scan_rate_limiter( - redis_client=None, - rate_limit=5, - rate_window=60, - burst_limit=10, - burst_window=3600, - ) - assert limiter._redis is None \ No newline at end of file diff --git a/testing/backend/unit/test_cli.py b/testing/backend/unit/test_cli.py index 00529a0d7..3256abb06 100644 --- a/testing/backend/unit/test_cli.py +++ b/testing/backend/unit/test_cli.py @@ -82,128 +82,3 @@ def test_cli_help_menu(): main() assert exc_info.value.code == 0 mock_print_help.assert_called_once() - - -def test_cli_no_args_calls_print_help(): - """With no arguments, main() calls print_help and returns (no SystemExit).""" - with patch("argparse.ArgumentParser.print_help") as mock_print_help, \ - patch("sys.argv", ["secuscan"]): - main() # should not raise, just calls print_help - mock_print_help.assert_called_once() - - -@pytest.mark.anyio -async def test_run_scan_target_dot_defaults_to_secret_scanner(): - """When target is '.', run_scan defaults to secret_scanner plugin.""" - mock_plugin = MagicMock() - mock_plugin.name = "Secret Scanner" - - mock_pm = MagicMock() - mock_pm.get_plugin.return_value = mock_plugin - mock_pm.plugins = {"secret_scanner": mock_plugin} - - mock_executor = MagicMock() - mock_executor.create_task = AsyncMock(return_value="task-dot-1") - mock_executor.execute_task = AsyncMock() - - mock_queue = AsyncMock() - mock_queue.get.side_effect = [{"type": "status", "data": "completed"}] - mock_executor.subscribe.return_value = mock_queue - - mock_db = AsyncMock() - mock_db.fetchone.return_value = { - "id": "task-dot-1", - "plugin_id": "secret_scanner", - "tool_name": "secret_scanner", - "target": ".", - "status": "completed", - "created_at": "2026-01-01", - "preset": None, - "inputs_json": "{}", - "command_used": "", - "structured_json": "{}", - } - - with patch("backend.secuscan.cli.init_db", new_callable=AsyncMock), \ - patch("backend.secuscan.cli.init_cache", new_callable=AsyncMock), \ - patch("backend.secuscan.cli.init_plugins", new_callable=AsyncMock), \ - patch("backend.secuscan.cli.get_plugin_manager", return_value=mock_pm), \ - patch("backend.secuscan.cli.executor", mock_executor), \ - patch("backend.secuscan.cli.get_db", return_value=mock_db): - - result = await run_scan(".", "nmap", "console") - assert result == 0 - mock_executor.create_task.assert_called_once() - - -@pytest.mark.anyio -async def test_run_scan_task_not_found_returns_1(): - """When the task record is missing from DB after execution, run_scan returns 1.""" - mock_plugin = MagicMock() - mock_plugin.name = "Nmap" - - mock_pm = MagicMock() - mock_pm.get_plugin.return_value = mock_plugin - - mock_executor = MagicMock() - mock_executor.create_task = AsyncMock(return_value="task-missing-1") - mock_executor.execute_task = AsyncMock() - - mock_queue = AsyncMock() - mock_queue.get.side_effect = [{"type": "status", "data": "completed"}] - mock_executor.subscribe.return_value = mock_queue - - mock_db = AsyncMock() - mock_db.fetchone.return_value = None # task record gone - - with patch("backend.secuscan.cli.init_db", new_callable=AsyncMock), \ - patch("backend.secuscan.cli.init_cache", new_callable=AsyncMock), \ - patch("backend.secuscan.cli.init_plugins", new_callable=AsyncMock), \ - patch("backend.secuscan.cli.get_plugin_manager", return_value=mock_pm), \ - patch("backend.secuscan.cli.executor", mock_executor), \ - patch("backend.secuscan.cli.get_db", return_value=mock_db): - - result = await run_scan("127.0.0.1", "nmap", "console") - assert result == 1 - - -@pytest.mark.anyio -async def test_run_scan_failed_task_returns_1(): - """When task status is 'failed', run_scan returns 1 without printing a report.""" - mock_plugin = MagicMock() - mock_plugin.name = "Nmap" - - mock_pm = MagicMock() - mock_pm.get_plugin.return_value = mock_plugin - - mock_executor = MagicMock() - mock_executor.create_task = AsyncMock(return_value="task-failed-1") - mock_executor.execute_task = AsyncMock() - - mock_queue = AsyncMock() - mock_queue.get.side_effect = [{"type": "status", "data": "failed"}] - mock_executor.subscribe.return_value = mock_queue - - mock_db = AsyncMock() - mock_db.fetchone.return_value = { - "id": "task-failed-1", - "plugin_id": "nmap", - "tool_name": "nmap", - "target": "127.0.0.1", - "status": "failed", - "created_at": "2026-01-01", - "preset": None, - "inputs_json": "{}", - "command_used": "", - "structured_json": None, - } - - with patch("backend.secuscan.cli.init_db", new_callable=AsyncMock), \ - patch("backend.secuscan.cli.init_cache", new_callable=AsyncMock), \ - patch("backend.secuscan.cli.init_plugins", new_callable=AsyncMock), \ - patch("backend.secuscan.cli.get_plugin_manager", return_value=mock_pm), \ - patch("backend.secuscan.cli.executor", mock_executor), \ - patch("backend.secuscan.cli.get_db", return_value=mock_db): - - result = await run_scan("127.0.0.1", "nmap", "console") - assert result == 1 diff --git a/testing/backend/unit/test_finding_intelligence_asset_ref.py b/testing/backend/unit/test_finding_intelligence_asset_ref.py deleted file mode 100644 index eb6cc4da9..000000000 --- a/testing/backend/unit/test_finding_intelligence_asset_ref.py +++ /dev/null @@ -1,197 +0,0 @@ -""" -Unit tests for finding_intelligence asset reference and signature helper functions. - -Covers: -- _extract_best_url: extracts best URL from finding metadata/evidence/target -- _guess_asset_ref: determines the asset_ref for a finding -- _issue_signature: generates a stable deduplication key from finding fields -- _finding_kind_for: classifies a finding as observation, suspected_issue, or validated_issue -""" - -from __future__ import annotations - -import pytest - -from backend.secuscan.finding_intelligence import ( - _extract_best_url, - _guess_asset_ref, - _issue_signature, - _finding_kind_for, -) - - -# --------------------------------------------------------------------------- -# _extract_best_url -# --------------------------------------------------------------------------- - -class TestExtractBestUrl: - def test_url_in_metadata_url(self): - finding = {"metadata": {"url": "https://example.com/login"}} - assert _extract_best_url(finding) == "https://example.com/login" - - def test_url_in_metadata_matched_at(self): - finding = {"metadata": {"matched_at": "https://example.com/api"}} - assert _extract_best_url(finding) == "https://example.com/api" - - def test_url_in_metadata_endpoint(self): - finding = {"metadata": {"endpoint": "https://example.com/admin"}} - assert _extract_best_url(finding) == "https://example.com/admin" - - def test_url_in_evidence(self): - finding = {"evidence": [{"value": "https://example.com/admin"}]} - assert _extract_best_url(finding) == "https://example.com/admin" - - def test_url_in_evidence_skips_non_url(self): - finding = {"evidence": [{"value": "not-a-url"}]} - assert _extract_best_url(finding) == "" - - def test_falls_back_to_target(self): - finding = {"target": "https://example.com"} - assert _extract_best_url(finding) == "https://example.com" - - def test_non_url_target_returns_empty(self): - finding = {"target": "/path/only"} - assert _extract_best_url(finding) == "" - - def test_empty_finding_returns_empty(self): - assert _extract_best_url({}) == "" - - def test_metadata_not_dict(self): - finding = {"metadata": "not-a-dict"} - assert _extract_best_url(finding) == "" - - def test_evidence_not_list(self): - finding = {"evidence": "not-a-list"} - assert _extract_best_url(finding) == "" - - -# --------------------------------------------------------------------------- -# _guess_asset_ref -# --------------------------------------------------------------------------- - -class TestGuessAssetRef: - def test_uses_existing_asset_ref(self): - finding = {"asset_refs": ["https://example.com/admin"]} - assert _guess_asset_ref(finding, "https://example.com") == "https://example.com/admin" - - def test_extracts_from_best_url(self): - finding = {"metadata": {"url": "https://example.com:8080/api"}} - assert "example.com" in _guess_asset_ref(finding, "https://example.com") - - def test_uses_host_from_metadata(self): - finding = {"metadata": {"host": "evil.com", "port": 443, "protocol": "tcp"}} - result = _guess_asset_ref(finding, "https://example.com") - assert "evil.com" in result - - def test_falls_back_to_target(self): - finding = {} - result = _guess_asset_ref(finding, "https://example.com") - assert "example.com" in result - - def test_empty_asset_refs_returns_empty(self): - finding = {"asset_refs": []} - assert _guess_asset_ref(finding, "") == "" - - -# --------------------------------------------------------------------------- -# _issue_signature -# --------------------------------------------------------------------------- - -class TestIssueSignature: - def test_cve_prefix(self): - finding = {"cve": "CVE-2021-44228"} - assert _issue_signature(finding).startswith("cve:") - - def test_cve_normalized_to_lowercase(self): - finding = {"cve": "CVE-2021-44228"} - sig = _issue_signature(finding) - assert sig == "cve:cve-2021-44228" - - def test_no_cve_uses_fields(self): - finding = { - "category": "Transport Security", - "title": "Missing CSP", - "validation_method": "header-check", - } - sig = _issue_signature(finding) - assert "transport-security" in sig - assert "missing-csp" in sig - assert "header-check" in sig - - def test_metadata_detail_used_in_signature(self): - finding = { - "category": "api exposure", - "title": "Open API Endpoint", - "metadata": {"service": "graphql"}, - } - sig = _issue_signature(finding) - assert "graphql" in sig - - def test_empty_finding_returns_non_empty_string(self): - sig = _issue_signature({}) - # Returns a non-empty string (falls back to 'finding' when compact is all dashes) - assert isinstance(sig, str) - assert len(sig) > 0 - - def test_signature_is_deterministic(self): - finding = {"category": "a", "title": "b"} - sig1 = _issue_signature(finding) - sig2 = _issue_signature(finding) - assert sig1 == sig2 - - def test_different_finding_different_signature(self): - sig1 = _issue_signature({"category": "a", "title": "b"}) - sig2 = _issue_signature({"category": "a", "title": "c"}) - assert sig1 != sig2 - - -# --------------------------------------------------------------------------- -# _finding_kind_for -# --------------------------------------------------------------------------- - -class TestFindingKindFor: - def test_validated_observation_category_is_observation(self): - # "information disclosure" is in _OBSERVATION_CATEGORIES, so validated_issue - # requires category NOT in _OBSERVATION_CATEGORIES - finding = {"validated": True, "category": "information disclosure", "severity": "high"} - assert _finding_kind_for(finding) == "observation" - - def test_observation_category_no_cve_is_observation(self): - finding = {"category": "asset discovery", "severity": "info"} - assert _finding_kind_for(finding) == "observation" - - def test_critical_severity_is_suspected_issue(self): - finding = {"severity": "critical"} - assert _finding_kind_for(finding) == "suspected_issue" - - def test_high_severity_is_suspected_issue(self): - finding = {"severity": "high"} - assert _finding_kind_for(finding) == "suspected_issue" - - def test_medium_severity_is_suspected_issue(self): - finding = {"severity": "medium"} - assert _finding_kind_for(finding) == "suspected_issue" - - def test_cve_is_suspected_issue(self): - finding = {"cve": "CVE-2021-44228", "category": "asset discovery"} - assert _finding_kind_for(finding) == "suspected_issue" - - def test_cpe_cve_correlation_observation_category_is_observation(self): - # "asset discovery" is in _OBSERVATION_CATEGORIES, returns observation - finding = {"validation_method": "cpe_cve_correlation", "category": "asset discovery"} - assert _finding_kind_for(finding) == "observation" - - def test_low_severity_no_cve_is_observation(self): - finding = {"severity": "low"} - assert _finding_kind_for(finding) == "observation" - - def test_info_severity_is_observation(self): - finding = {"severity": "info", "category": "transport security"} - assert _finding_kind_for(finding) == "observation" - - def test_observation_category_with_cve_is_suspected(self): - finding = {"category": "service exposure", "cve": "CVE-2021-44228"} - assert _finding_kind_for(finding) == "suspected_issue" - - def test_empty_finding_is_observation(self): - assert _finding_kind_for({}) == "observation" diff --git a/testing/backend/unit/test_finding_intelligence_asset_summary.py b/testing/backend/unit/test_finding_intelligence_asset_summary.py deleted file mode 100644 index 246b0d5e4..000000000 --- a/testing/backend/unit/test_finding_intelligence_asset_summary.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Unit tests for finding_intelligence build_asset_summary and related helpers. - -Covers: -- build_asset_summary: groups findings by asset and computes per-asset stats -- _normalize_url_path: extracts and normalizes URL paths -- _typed_evidence: normalizes evidence items into a standard dict format -""" - -from __future__ import annotations - -import pytest - -from backend.secuscan.finding_intelligence import ( - _normalize_url_path, - _typed_evidence, - build_asset_summary, -) - - -# --------------------------------------------------------------------------- -# _normalize_url_path -# --------------------------------------------------------------------------- - -class TestNormalizeUrlPath: - def test_full_url(self): - result = _normalize_url_path("https://example.com/admin/users") - assert result == "/admin/users" - - def test_url_root(self): - result = _normalize_url_path("https://example.com/") - assert result == "/" - - def test_url_trailing_slash_normalized(self): - result = _normalize_url_path("https://example.com/api/") - assert result == "/api" - - def test_relative_path(self): - result = _normalize_url_path("/api/v2/users") - assert result == "/api/v2/users" - - def test_relative_path_trailing_slash(self): - result = _normalize_url_path("/api/v2/") - assert result == "/api/v2" - - def test_empty_relative_path(self): - result = _normalize_url_path("/") - assert result == "/" - - def test_empty_string(self): - result = _normalize_url_path("") - assert result == "" - - def test_path_only_no_leading_slash(self): - result = _normalize_url_path("api/users") - assert result == "" - - -# --------------------------------------------------------------------------- -# _typed_evidence -# --------------------------------------------------------------------------- - -class TestTypedEvidence: - def test_dict_item(self): - result = _typed_evidence( - {"type": "header", "label": "Server", "value": "nginx"}, - source="nuclei", - observed_at="2024-01-01T00:00:00Z", - confidence=0.85, - ) - assert result["type"] == "header" - assert result["label"] == "Server" - assert result["value"] == "nginx" - assert result["source"] == "nuclei" - assert result["observed_at"] == "2024-01-01T00:00:00Z" - assert result["confidence"] == 0.85 - - def test_non_dict_item(self): - result = _typed_evidence( - "plain text evidence", - source="nuclei", - observed_at="2024-01-01T00:00:00Z", - confidence=0.85, - ) - assert result["type"] == "evidence" - assert result["label"] == "Evidence" - assert result["value"] == "plain text evidence" - assert result["source"] == "nuclei" - assert result["confidence"] == 0.85 - - def test_confidence_clamped_to_valid_range(self): - result = _typed_evidence( - {"value": "x", "confidence": 1.5}, - source="nuclei", - observed_at="2024-01-01T00:00:00Z", - confidence=0.85, - ) - assert result["confidence"] == 1.0 # clamped to max - - def test_confidence_clamped_to_zero(self): - result = _typed_evidence( - {"value": "x", "confidence": -0.5}, - source="nuclei", - observed_at="2024-01-01T00:00:00Z", - confidence=0.85, - ) - assert result["confidence"] == 0.0 # clamped to min - - def test_item_source_overrides_default(self): - result = _typed_evidence( - {"value": "x", "source": "nikto"}, - source="nuclei", - observed_at="2024-01-01T00:00:00Z", - confidence=0.85, - ) - assert result["source"] == "nikto" - - def test_item_observed_at_overrides_default(self): - result = _typed_evidence( - {"value": "x", "observed_at": "2024-06-01T00:00:00Z"}, - source="nuclei", - observed_at="2024-01-01T00:00:00Z", - confidence=0.85, - ) - assert result["observed_at"] == "2024-06-01T00:00:00Z" - - def test_artifact_ref_preserved(self): - result = _typed_evidence( - {"value": "x", "artifact_ref": "artifact:123"}, - source="nuclei", - observed_at="2024-01-01T00:00:00Z", - confidence=0.85, - ) - assert result["artifact_ref"] == "artifact:123" - - -# --------------------------------------------------------------------------- -# build_asset_summary -# --------------------------------------------------------------------------- - -class TestBuildAssetSummary: - def test_empty_inputs(self): - result = build_asset_summary([], []) - assert result == [] - - def test_findings_only(self): - findings = [ - { - "asset_id": "asset:abc", - "target": "https://example.com", - "severity": "high", - "validated": True, - }, - { - "asset_id": "asset:abc", - "target": "https://example.com", - "severity": "low", - "validated": False, - }, - { - "asset_id": "asset:xyz", - "target": "https://test.com", - "severity": "critical", - "validated": True, - }, - ] - result = build_asset_summary(findings, []) - assert len(result) == 2 - asset_abc = next(a for a in result if a["asset_id"] == "asset:abc") - assert asset_abc["finding_count"] == 2 - assert asset_abc["validated_count"] == 1 - assert asset_abc["highest_severity"] == "high" - - def test_services_only(self): - services = [ - {"asset_id": "asset:svc1", "host": "example.com", "target": "https://example.com"}, - {"asset_id": "asset:svc2", "host": "test.com", "target": "https://test.com"}, - ] - result = build_asset_summary([], services) - assert len(result) == 2 - assert result[0]["finding_count"] == 0 - assert result[0]["validated_count"] == 0 - - def test_highest_severity_takes_max(self): - findings = [ - {"asset_id": "asset:test", "severity": "info"}, - {"asset_id": "asset:test", "severity": "critical"}, - {"asset_id": "asset:test", "severity": "low"}, - ] - result = build_asset_summary(findings, []) - assert len(result) == 1 - assert result[0]["highest_severity"] == "critical" - - def test_results_sorted_by_severity_then_count(self): - findings = [ - {"asset_id": "asset:a", "severity": "low"}, - {"asset_id": "asset:b", "severity": "critical"}, - {"asset_id": "asset:c", "severity": "critical"}, - ] - result = build_asset_summary(findings, []) - # critical assets come first, then sorted by label - critical_assets = [a for a in result if a["highest_severity"] == "critical"] - low_assets = [a for a in result if a["highest_severity"] == "low"] - assert all(a["highest_severity"] == "critical" for a in critical_assets) - assert all(a["highest_severity"] == "low" for a in low_assets) - - def test_missing_asset_id_uses_stable_id(self): - findings = [ - {"target": "https://example.com", "severity": "high"}, - ] - result = build_asset_summary(findings, []) - assert len(result) == 1 - assert result[0]["finding_count"] == 1 - assert result[0]["highest_severity"] == "high" diff --git a/testing/backend/unit/test_finding_intelligence_confidence.py b/testing/backend/unit/test_finding_intelligence_confidence.py deleted file mode 100644 index ca71cd106..000000000 --- a/testing/backend/unit/test_finding_intelligence_confidence.py +++ /dev/null @@ -1,335 +0,0 @@ -""" -Unit tests for finding_intelligence confidence and severity helper functions. - -Covers: -- _normalize_severity: maps severity strings to canonical labels -- _severity_rank: returns comparable integer rank for severity -- _source_quality: scores source reliability from SOURCE_QUALITY map -- _fingerprint_score: returns (score, match_strength) from finding metadata -- _build_confidence_reason: composes human-readable confidence explanation -- _compute_confidence: combines multiple factors into a 0-1 confidence score -- _sort_sources: sorts and deduplicates source strings -""" - -from __future__ import annotations - -import pytest - -from backend.secuscan.finding_intelligence import ( - _normalize_severity, - _severity_rank, - _source_quality, - _fingerprint_score, - _build_confidence_reason, - _compute_confidence, - _sort_sources, -) - - -# --------------------------------------------------------------------------- -# _normalize_severity -# --------------------------------------------------------------------------- - -class TestNormalizeSeverity: - def test_known_severities(self): - assert _normalize_severity("critical") == "critical" - assert _normalize_severity("high") == "high" - assert _normalize_severity("medium") == "medium" - assert _normalize_severity("moderate") == "medium" - assert _normalize_severity("low") == "low" - assert _normalize_severity("info") == "info" - assert _normalize_severity("informational") == "info" - assert _normalize_severity("note") == "info" - - def test_case_insensitive(self): - assert _normalize_severity("CRITICAL") == "critical" - assert _normalize_severity("High") == "high" - assert _normalize_severity("MeDiUm") == "medium" - - def test_unknown_defaults_to_info(self): - assert _normalize_severity("unknown") == "info" - assert _normalize_severity("") == "info" - assert _normalize_severity(None) == "info" - - -# --------------------------------------------------------------------------- -# _severity_rank -# --------------------------------------------------------------------------- - -class TestSeverityRank: - def test_rank_order(self): - assert _severity_rank("critical") == 5 - assert _severity_rank("high") == 4 - assert _severity_rank("medium") == 3 - assert _severity_rank("low") == 2 - assert _severity_rank("info") == 1 - - def test_unknown_defaults_to_info_rank(self): - assert _severity_rank("unknown") == 1 - assert _severity_rank("") == 1 - assert _severity_rank(None) == 1 - - def test_rank_is_comparable(self): - assert _severity_rank("critical") > _severity_rank("high") - assert _severity_rank("high") > _severity_rank("medium") - assert _severity_rank("medium") > _severity_rank("low") - assert _severity_rank("low") > _severity_rank("info") - - -# --------------------------------------------------------------------------- -# _source_quality -# --------------------------------------------------------------------------- - -class TestSourceQuality: - def test_known_sources(self): - assert _source_quality(["nuclei"]) == 0.8 - assert _source_quality(["nikto"]) == 0.7 - assert _source_quality(["nmap"]) == 0.78 - assert _source_quality(["http_probe"]) == 0.82 - - def test_multiple_sources_returns_max(self): - assert _source_quality(["nikto", "nuclei"]) == 0.8 - assert _source_quality(["crawl", "openapi"]) == 0.8 - - def test_unknown_source_uses_default(self): - assert _source_quality(["unknown_scanner"]) == 0.58 - - def test_empty_source_list(self): - assert _source_quality([]) == 0.58 - - def test_whitespace_source_uses_default(self): - assert _source_quality([" "]) == 0.58 - - -# --------------------------------------------------------------------------- -# _fingerprint_score -# --------------------------------------------------------------------------- - -class TestFingerprintScore: - def test_validated_match(self): - score, strength = _fingerprint_score({"validated": True}) - assert score == 1.0 - assert strength == "validated" - - def test_exact_match(self): - score, strength = _fingerprint_score( - {"metadata": {"match_strength": "exact"}} - ) - assert score == 0.95 - assert strength == "exact" - - def test_strong_fuzzy(self): - score, strength = _fingerprint_score( - {"metadata": {"match_strength": "strong_fuzzy"}} - ) - assert score == 0.8 - assert strength == "strong_fuzzy" - - def test_fuzzy(self): - score, strength = _fingerprint_score( - {"metadata": {"match_strength": "fuzzy"}} - ) - assert score == 0.7 - assert strength == "fuzzy" - - def test_family(self): - score, strength = _fingerprint_score( - {"metadata": {"match_strength": "family"}} - ) - assert score == 0.45 - assert strength == "family" - - def test_none_match(self): - score, strength = _fingerprint_score({"metadata": {}}) - assert score == 0.25 - assert strength == "none" - - def test_missing_metadata(self): - score, strength = _fingerprint_score({}) - assert score == 0.25 - assert strength == "none" - - -# --------------------------------------------------------------------------- -# _build_confidence_reason -# --------------------------------------------------------------------------- - -class TestBuildConfidenceReason: - def test_basic_reason(self): - result = _build_confidence_reason( - finding_kind="observation", - evidence_count=3, - corroborating_sources=["nuclei"], - occurrence_count=1, - match_strength="none", - ) - assert "Observation" in result - assert "3 evidence items" in result - assert "1 source" in result - assert result.endswith(".") - - def test_singular_evidence(self): - result = _build_confidence_reason( - finding_kind="suspected_issue", - evidence_count=1, - corroborating_sources=[], - occurrence_count=1, - match_strength="none", - ) - assert "evidence item" in result - - def test_multiple_occurrences(self): - result = _build_confidence_reason( - finding_kind="validated_issue", - evidence_count=5, - corroborating_sources=["nuclei", "nmap"], - occurrence_count=3, - match_strength="fuzzy", - ) - assert "3 scan observations" in result - - def test_fingerprint_match(self): - result = _build_confidence_reason( - finding_kind="suspected_issue", - evidence_count=2, - corroborating_sources=[], - occurrence_count=1, - match_strength="exact", - ) - assert "exact fingerprint match" in result - - def test_empty_match_strength(self): - result = _build_confidence_reason( - finding_kind="observation", - evidence_count=2, - corroborating_sources=[], - occurrence_count=1, - match_strength="", - ) - assert result # should not crash - - -# --------------------------------------------------------------------------- -# _compute_confidence -# --------------------------------------------------------------------------- - -class TestComputeConfidence: - def test_returns_float_in_valid_range(self): - score = _compute_confidence( - {}, - corroborating_sources=["nuclei"], - occurrence_count=1, - evidence=[], - ) - assert 0.0 <= score <= 0.99 - assert isinstance(score, float) - - def test_high_value_sources_increase_score(self): - low = _compute_confidence( - {}, - corroborating_sources=["nikto"], - occurrence_count=1, - evidence=[], - ) - high = _compute_confidence( - {}, - corroborating_sources=["openapi"], - occurrence_count=1, - evidence=[], - ) - assert high > low - - def test_more_evidence_increases_score(self): - no_evidence = _compute_confidence( - {}, - corroborating_sources=[], - occurrence_count=1, - evidence=[], - ) - with_evidence = _compute_confidence( - {}, - corroborating_sources=[], - occurrence_count=1, - evidence=[{}, {}, {}, {}], - ) - assert with_evidence > no_evidence - - def test_more_occurrences_increases_score(self): - single = _compute_confidence( - {}, - corroborating_sources=[], - occurrence_count=1, - evidence=[], - ) - repeated = _compute_confidence( - {}, - corroborating_sources=[], - occurrence_count=5, - evidence=[], - ) - assert repeated > single - - def test_validated_finding_increases_score(self): - unvalidated = _compute_confidence( - {"validated": False}, - corroborating_sources=[], - occurrence_count=1, - evidence=[], - ) - validated = _compute_confidence( - {"validated": True}, - corroborating_sources=[], - occurrence_count=1, - evidence=[], - ) - assert validated > unvalidated - - def test_critical_severity_increases_score(self): - info = _compute_confidence( - {"severity": "info"}, - corroborating_sources=[], - occurrence_count=1, - evidence=[], - ) - critical = _compute_confidence( - {"severity": "critical"}, - corroborating_sources=[], - occurrence_count=1, - evidence=[], - ) - assert critical > info - - def test_score_is_capped_at_0_99(self): - # Very strong finding - score = _compute_confidence( - {"validated": True, "severity": "critical"}, - corroborating_sources=["openapi", "graphql", "nuclei"], - occurrence_count=10, - evidence=[{}, {}, {}, {}], - ) - assert score <= 0.99 - - -# --------------------------------------------------------------------------- -# _sort_sources -# --------------------------------------------------------------------------- - -class TestSortSources: - def test_removes_duplicates(self): - result = _sort_sources(["nuclei", "nuclei", "nikto"]) - assert result == ["nikto", "nuclei"] - - def test_sorts_alphabetically(self): - result = _sort_sources(["zap", "nuclei", "nikto"]) - assert result == ["nikto", "nuclei", "zap"] - - def test_whitespace_is_stripped(self): - result = _sort_sources([" nuclei ", "nikto"]) - assert "nuclei" in result - assert " nuclei " not in result - - def test_empty_and_whitespace_removed(self): - result = _sort_sources(["nuclei", "", " ", None]) - assert "nuclei" in result - assert "" not in result - assert " " not in result diff --git a/testing/backend/unit/test_finding_intelligence_timestamp.py b/testing/backend/unit/test_finding_intelligence_timestamp.py deleted file mode 100644 index 1f7f3fb0b..000000000 --- a/testing/backend/unit/test_finding_intelligence_timestamp.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Unit tests for finding_intelligence timestamp and ID generation helpers. - -Covers: -- _parse_timestamp: converts datetime objects or ISO strings to UTC ISO format -- _stable_id: generates a deterministic short SHA-based ID from prefix and parts -""" - -from __future__ import annotations - -import pytest -from datetime import datetime, timezone - -from backend.secuscan.finding_intelligence import _parse_timestamp, _stable_id - - -# --------------------------------------------------------------------------- -# _parse_timestamp -# --------------------------------------------------------------------------- - -class TestParseTimestamp: - def test_aware_datetime_returns_iso(self): - aware = datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc) - result = _parse_timestamp(aware) - assert result.startswith("2024-06-01") - - def test_naive_datetime_returns_iso(self): - naive = datetime(2024, 6, 1, 12, 0, 0) - result = _parse_timestamp(naive) - assert result.startswith("2024-06-01") - - def test_iso_string_with_z_suffix(self): - result = _parse_timestamp("2024-06-01T12:00:00Z") - assert result.startswith("2024-06-01") - - def test_iso_string_without_z_suffix(self): - result = _parse_timestamp("2024-06-01T12:00:00+00:00") - assert result.startswith("2024-06-01") - - def test_invalid_string_returns_iso_now(self): - before = datetime.now(timezone.utc) - result = _parse_timestamp("not-a-date") - after = datetime.now(timezone.utc) - # Should return an ISO string near current time - assert result.endswith("+00:00") - parsed = datetime.fromisoformat(result) - assert before <= parsed <= after - - def test_empty_string_returns_iso_now(self): - result = _parse_timestamp("") - # Returns an ISO timestamp near now - assert "+00:00" in result or "Z" in result - parsed = datetime.fromisoformat(result.replace("Z", "+00:00")) - assert isinstance(parsed, datetime) - - def test_none_returns_iso_now(self): - before = datetime.now(timezone.utc) - result = _parse_timestamp(None) - after = datetime.now(timezone.utc) - parsed = datetime.fromisoformat(result.replace("Z", "+00:00")) - assert before <= parsed <= after - - def test_microseconds_preserved(self): - result = _parse_timestamp("2024-06-01T12:00:00.123456Z") - assert ".123456" in result - - def test_non_utc_timezone_converted_to_utc(self): - # +05:30 offset should be converted to UTC (+00:00) - result = _parse_timestamp("2024-06-01T12:00:00+05:30") - # 12:00 +05:30 = 06:30 UTC - assert result.startswith("2024-06-01") - # Verify the offset is UTC - assert result.endswith("+00:00") - - -# --------------------------------------------------------------------------- -# _stable_id -# --------------------------------------------------------------------------- - -class TestStableId: - def test_prefix_in_output(self): - sig = _stable_id("asset", "example.com") - assert sig.startswith("asset:") - - def test_same_inputs_produce_same_output(self): - sig1 = _stable_id("asset", "example.com", "443") - sig2 = _stable_id("asset", "example.com", "443") - assert sig1 == sig2 - - def test_different_inputs_produce_different_output(self): - sig1 = _stable_id("asset", "example.com") - sig2 = _stable_id("asset", "different.com") - assert sig1 != sig2 - - def test_different_prefixes_produce_different_output(self): - sig1 = _stable_id("asset", "example.com") - sig2 = _stable_id("group", "example.com") - assert sig1 != sig2 - - def test_none_parts_handled(self): - sig = _stable_id("asset", None, "example.com") - assert sig.startswith("asset:") - - def test_empty_parts_handled(self): - sig = _stable_id("asset", "", "example.com") - assert sig.startswith("asset:") - - def test_whitespace_parts_normalized(self): - sig1 = _stable_id("asset", " example.com ") - sig2 = _stable_id("asset", "example.com") - assert sig1 == sig2 - - def test_case_normalized(self): - sig1 = _stable_id("asset", "EXAMPLE.COM") - sig2 = _stable_id("asset", "example.com") - assert sig1 == sig2 - - def test_id_has_correct_length(self): - sig = _stable_id("asset", "example.com") - # format: prefix:SHA (prefix + : + 16-char hex digest) - assert len(sig) == len("asset:") + 16 - - def test_many_parts(self): - sig = _stable_id("finding", "a", "b", "c", "d", "e") - assert sig.startswith("finding:") diff --git a/testing/backend/unit/test_notification_service.py b/testing/backend/unit/test_notification_service.py index a247091b2..826d0bd6f 100644 --- a/testing/backend/unit/test_notification_service.py +++ b/testing/backend/unit/test_notification_service.py @@ -683,57 +683,3 @@ async def tracking_connect_tcp( assert tls_hostname == "hooks.example.com", ( f"TLS SNI must use original hostname (hooks.example.com), got {tls_hostname!r}" ) - - -@pytest.mark.asyncio -async def test_process_slack_notification_success(test_db, monkeypatch): - """process_slack_notification compiles task info, counts findings, and sends webhook successfully.""" - task_id, finding_id = await _seed_finding(test_db, severity="high") - - monkeypatch.setattr(settings, "slack_webhook_url", "https://slack.example.invalid/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX") - - mock_send = AsyncMock(return_value=(True, None)) - monkeypatch.setattr("backend.secuscan.notification_service.send_webhook", mock_send) - - from backend.secuscan.notification_service import process_slack_notification - await process_slack_notification(test_db, task_id) - - assert mock_send.call_count == 1 - call_args, _ = mock_send.call_args - target_url = call_args[0] - payload = call_args[1] - - assert target_url == "https://slack.example.invalid/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" - assert "blocks" in payload - assert len(payload["blocks"]) >= 3 - assert "High" in payload["blocks"][2]["fields"][1]["text"] - assert "Total Findings" in payload["blocks"][2]["fields"][0]["text"] - - -@pytest.mark.asyncio -async def test_process_slack_notification_failed_task(test_db, monkeypatch): - """process_slack_notification sends error details when status is FAILED.""" - task_id = str(uuid.uuid4()) - await test_db.execute( - """ - INSERT INTO tasks ( - id, plugin_id, tool_name, target, status, inputs_json, consent_granted, error_message - ) VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'failed', '{}', 1, 'Connection refused') - """, - (task_id,), - ) - - monkeypatch.setattr(settings, "slack_webhook_url", "https://slack.example.invalid/services/test") - mock_send = AsyncMock(return_value=(True, None)) - monkeypatch.setattr("backend.secuscan.notification_service.send_webhook", mock_send) - - from backend.secuscan.notification_service import process_slack_notification - await process_slack_notification(test_db, task_id) - - assert mock_send.call_count == 1 - call_args, _ = mock_send.call_args - payload = call_args[1] - - assert "blocks" in payload - assert "Error Message" in payload["blocks"][2]["text"]["text"] - assert "Connection refused" in payload["blocks"][2]["text"]["text"] diff --git a/testing/backend/unit/test_ratelimit.py b/testing/backend/unit/test_ratelimit.py index 04ca1a0ac..6a73f46f0 100644 --- a/testing/backend/unit/test_ratelimit.py +++ b/testing/backend/unit/test_ratelimit.py @@ -1,200 +1,153 @@ """ -Unit tests for RateLimiter and WorkflowRateLimiter helpers in -backend.secuscan.ratelimit. - -Covers (separately from test_endpoint_rate_limiter.py which covers -EndpointRateLimiter and resolve_client_identity): -- RateLimiter.can_execute: quota enforcement, cleanup, independent buckets -- RateLimiter.reset: per-plugin and global reset -- WorkflowRateLimiter.check_workflow_rate_limit: interval enforcement +Unit tests for backend.secuscan.ratelimit RateLimiter and ConcurrentTaskLimiter. + +Covers: +- RateLimiter.can_execute allows requests within the hourly quota +- RateLimiter.can_execute blocks when hourly quota is exhausted +- RateLimiter.can_execute tracks per-client-per-plugin independently +- RateLimiter.can_execute expires old entries after 1 hour (sliding window) +- RateLimiter.reset clears a specific plugin or all buckets +- ConcurrentTaskLimiter.acquire succeeds while under the concurrent limit +- ConcurrentTaskLimiter.acquire fails when the concurrent limit is reached +- ConcurrentTaskLimiter.release removes a task from the running list +- ConcurrentTaskLimiter.get_available_slots returns correct available count """ -import asyncio -from datetime import datetime, timedelta - import pytest -from backend.secuscan.ratelimit import RateLimiter, WorkflowRateLimiter - - -# --------------------------------------------------------------------------- -# RateLimiter.can_execute -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_can_execute_allows_under_quota(): - """When below max_per_hour, can_execute returns (True, '').""" - limiter = RateLimiter() - allowed, msg = await limiter.can_execute("plugin1", max_per_hour=10, client_id="alice") - assert allowed is True - assert msg == "" +from backend.secuscan.ratelimit import RateLimiter, ConcurrentTaskLimiter @pytest.mark.asyncio -async def test_can_execute_denies_at_quota(): - """When at max_per_hour, can_execute returns (False, error_message).""" - limiter = RateLimiter() - for _ in range(5): - await limiter.can_execute("plugin1", max_per_hour=5, client_id="alice") - - allowed, msg = await limiter.can_execute("plugin1", max_per_hour=5, client_id="alice") - assert allowed is False - assert "Rate limit exceeded" in msg - assert "5/5" in msg - - -@pytest.mark.asyncio -async def test_can_execute_cleans_old_entries(): - """Entries older than 1 hour are removed so they no longer count toward quota.""" - limiter = RateLimiter() - - # Manually inject an old entry - bucket = "alice:plugin1" - old_time = datetime.now() - timedelta(hours=2) - limiter.task_history[bucket].append(old_time) - - # Should allow since the old entry was cleaned up - allowed, msg = await limiter.can_execute("plugin1", max_per_hour=5, client_id="alice") - assert allowed is True - assert len(limiter.task_history[bucket]) == 1 # old entry removed, new one added - - -@pytest.mark.asyncio -async def test_can_execute_independent_per_client(): - """Each client_id has an independent quota.""" - limiter = RateLimiter() - - for _ in range(3): - await limiter.can_execute("plugin1", max_per_hour=3, client_id="alice") - - # Alice is at quota - allowed_alice, _ = await limiter.can_execute("plugin1", max_per_hour=3, client_id="alice") - assert allowed_alice is False - - # Bob has separate quota - allowed_bob, _ = await limiter.can_execute("plugin1", max_per_hour=3, client_id="bob") - assert allowed_bob is True - - -@pytest.mark.asyncio -async def test_can_execute_independent_per_plugin(): - """Each plugin_id has an independent quota within the same client.""" - limiter = RateLimiter() - - for _ in range(3): - await limiter.can_execute("nmap", max_per_hour=3, client_id="alice") - - # nmap is at quota - allowed_nmap, _ = await limiter.can_execute("nmap", max_per_hour=3, client_id="alice") - assert allowed_nmap is False - - # http_inspector is independent - allowed_http, _ = await limiter.can_execute("http_inspector", max_per_hour=3, client_id="alice") - assert allowed_http is True - - -@pytest.mark.asyncio -async def test_can_execute_default_client_id_is_global(): - """When client_id is not supplied, defaults to 'global' bucket.""" - limiter = RateLimiter() - - for _ in range(3): - await limiter.can_execute("nmap", max_per_hour=3) - - allowed, msg = await limiter.can_execute("nmap", max_per_hour=3) - assert allowed is False - # The bucket should be "global:nmap" - assert "global:nmap" in limiter.task_history - - -# --------------------------------------------------------------------------- -# RateLimiter.reset -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_reset_clears_matching_plugin(): - """reset(plugin_id) clears all buckets for that plugin across all clients.""" - limiter = RateLimiter() - - await limiter.can_execute("nmap", max_per_hour=10, client_id="alice") - await limiter.can_execute("nmap", max_per_hour=10, client_id="bob") - await limiter.can_execute("http_inspector", max_per_hour=10, client_id="alice") - - assert len(limiter.task_history) > 0 - - await limiter.reset("nmap") - - # nmap buckets should be cleared - assert len(limiter.task_history["alice:nmap"]) == 0 - assert len(limiter.task_history["bob:nmap"]) == 0 - # http_inspector bucket should be untouched - assert len(limiter.task_history["alice:http_inspector"]) > 0 - - -@pytest.mark.asyncio -async def test_reset_clears_all_buckets(): - """reset() with no argument clears every bucket.""" - limiter = RateLimiter() - - await limiter.can_execute("nmap", max_per_hour=10, client_id="alice") - await limiter.can_execute("nmap", max_per_hour=10, client_id="bob") - - assert len(limiter.task_history) > 0 - - await limiter.reset() - - assert len(limiter.task_history) == 0 - - -# --------------------------------------------------------------------------- -# WorkflowRateLimiter.check_workflow_rate_limit -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_workflow_rate_limiter_allows_first_run(): - """A workflow with no prior run should always be allowed.""" - limiter = WorkflowRateLimiter() - allowed, msg = await limiter.check_workflow_rate_limit("wf-1", min_interval_seconds=300) - assert allowed is True - assert msg == "" - - -@pytest.mark.asyncio -async def test_workflow_rate_limiter_denies_within_interval(): - """A workflow run within the interval should be denied with a remaining-seconds message.""" - limiter = WorkflowRateLimiter() - - # First run - await limiter.check_workflow_rate_limit("wf-1", min_interval_seconds=300) - - # Second run immediately after - allowed, msg = await limiter.check_workflow_rate_limit("wf-1", min_interval_seconds=300) - assert allowed is False - assert "Workflow rate limited" in msg - # remaining should be close to 300 - assert "300" in msg or "299" in msg - - -@pytest.mark.asyncio -async def test_workflow_rate_limiter_allows_after_interval(): - """A workflow run after the interval has passed should be allowed.""" - limiter = WorkflowRateLimiter() - - # Manually inject a past run - limiter._last_run["wf-1"] = datetime.now() - timedelta(seconds=301) - - allowed, msg = await limiter.check_workflow_rate_limit("wf-1", min_interval_seconds=300) - assert allowed is True - assert msg == "" +class TestRateLimiter: + async def test_allows_within_quota(self): + """can_execute returns (True, '') for requests under the hourly limit.""" + limiter = RateLimiter() + allowed, msg = await limiter.can_execute("nmap", max_per_hour=5, client_id="c1") + assert allowed is True + assert msg == "" + + async def test_blocks_when_quota_exhausted(self): + """can_execute returns (False, error) once the hourly quota is reached.""" + limiter = RateLimiter() + # Exhaust the quota + for _ in range(3): + await limiter.can_execute("nmap", max_per_hour=3, client_id="c1") + + allowed, msg = await limiter.can_execute("nmap", max_per_hour=3, client_id="c1") + assert allowed is False + assert "exceeded" in msg.lower() + assert "3/3" in msg + + async def test_per_client_per_plugin_independently(self): + """Different client_ids or plugin_ids have independent quotas.""" + limiter = RateLimiter() + # Exhaust c1:nmap quota + for _ in range(2): + await limiter.can_execute("nmap", max_per_hour=2, client_id="c1") + + # c2 should still be able to use nmap + allowed, _ = await limiter.can_execute("nmap", max_per_hour=2, client_id="c2") + assert allowed is True + + # c1:dirbuster should still have quota + allowed, _ = await limiter.can_execute("dirbuster", max_per_hour=2, client_id="c1") + assert allowed is True + + async def test_default_client_id_is_global(self): + """When client_id is not provided, uses 'global' as the bucket key.""" + limiter = RateLimiter() + # Exhaust the global quota for nmap + for _ in range(2): + await limiter.can_execute("nmap", max_per_hour=2) + + allowed, _ = await limiter.can_execute("nmap", max_per_hour=2) + assert allowed is False + + async def test_reset_plugin_clears_only_that_plugin(self): + """reset(plugin_id) removes only buckets ending with :.""" + limiter = RateLimiter() + await limiter.can_execute("nmap", max_per_hour=1, client_id="c1") + await limiter.can_execute("dirbuster", max_per_hour=1, client_id="c1") + + await limiter.reset("nmap") + + # nmap bucket should be cleared + allowed, _ = await limiter.can_execute("nmap", max_per_hour=1, client_id="c1") + assert allowed is True + # dirbuster bucket should be untouched + allowed, _ = await limiter.can_execute("dirbuster", max_per_hour=1, client_id="c1") + assert allowed is False + + async def test_reset_all_clears_all_buckets(self): + """reset(None) clears every bucket.""" + limiter = RateLimiter() + await limiter.can_execute("nmap", max_per_hour=1, client_id="c1") + await limiter.can_execute("nmap", max_per_hour=1, client_id="c2") + + await limiter.reset() + + allowed, _ = await limiter.can_execute("nmap", max_per_hour=1, client_id="c1") + assert allowed is True + allowed, _ = await limiter.can_execute("nmap", max_per_hour=1, client_id="c2") + assert allowed is True @pytest.mark.asyncio -async def test_workflow_rate_limiter_each_workflow_independent(): - """Workflow rate limits are independent per workflow_id.""" - limiter = WorkflowRateLimiter() - - # Run wf-1 - await limiter.check_workflow_rate_limit("wf-1", min_interval_seconds=300) - # wf-2 has never run - allowed, msg = await limiter.check_workflow_rate_limit("wf-2", min_interval_seconds=300) - assert allowed is True +class TestConcurrentTaskLimiter: + async def test_acquire_succeeds_under_limit(self): + """acquire returns (True, '') when slots are available.""" + limiter = ConcurrentTaskLimiter(max_concurrent=3) + acquired, msg = await limiter.acquire("task-1") + assert acquired is True + assert msg == "" + + async def test_acquire_fails_at_limit(self): + """acquire returns (False, error) when all slots are occupied.""" + limiter = ConcurrentTaskLimiter(max_concurrent=2) + await limiter.acquire("task-1") + await limiter.acquire("task-2") + + acquired, msg = await limiter.acquire("task-3") + assert acquired is False + assert "Maximum concurrent tasks" in msg + + async def test_release_frees_a_slot(self): + """release(task_id) removes the task and frees its slot.""" + limiter = ConcurrentTaskLimiter(max_concurrent=2) + await limiter.acquire("task-1") + await limiter.acquire("task-2") + # At limit, next acquire should fail + acquired, _ = await limiter.acquire("task-3") + assert acquired is False + + # Release task-1, should free a slot + await limiter.release("task-1") + acquired, _ = await limiter.acquire("task-3") + assert acquired is True + + async def test_release_unknown_task_is_noop(self): + """release(task_id) where task_id is not in running_tasks is a no-op.""" + limiter = ConcurrentTaskLimiter(max_concurrent=2) + await limiter.acquire("task-1") + # Releasing a non-existent task should not affect state + await limiter.release("nonexistent-task") + # Still at capacity + acquired, _ = await limiter.acquire("task-2") + assert acquired is True + acquired, _ = await limiter.acquire("task-3") + assert acquired is False + + async def test_get_available_slots(self): + """get_available_slots returns max_concurrent minus running task count.""" + limiter = ConcurrentTaskLimiter(max_concurrent=3) + assert await limiter.get_available_slots() == 3 + + await limiter.acquire("task-1") + assert await limiter.get_available_slots() == 2 + + await limiter.acquire("task-2") + assert await limiter.get_available_slots() == 1 + + await limiter.release("task-1") + assert await limiter.get_available_slots() == 2 diff --git a/testing/backend/unit/test_sandbox_executor.py b/testing/backend/unit/test_sandbox_executor.py index 49e946ec5..e1f320ba0 100644 --- a/testing/backend/unit/test_sandbox_executor.py +++ b/testing/backend/unit/test_sandbox_executor.py @@ -1,188 +1,71 @@ """ -Unit tests for sandbox_executor.py pure helpers. - -Covers (separately from testing/backend/test_sandbox_executor.py which tests -sandbox_execute end-to-end): -- classify_memory_violation: exit-code, stderr, and RSS threshold heuristics -- resolve_sandbox_config: global defaults merged with per-plugin overrides +Unit tests for backend.secuscan.sandbox_executor pure helpers. + +Covers: +- resolve_sandbox_config returns global defaults when plugin_sandbox is None +- resolve_sandbox_config applies plugin overrides correctly +- classify_memory_violation returns True for SIGSEGV exit codes +- classify_memory_violation returns True for memory error messages +- classify_memory_violation returns True when RSS near limit and exit non-zero +- classify_memory_violation returns False for normal exit """ -from unittest.mock import MagicMock, patch - -import pytest - -from backend.secuscan.sandbox_executor import ( - classify_memory_violation, - resolve_sandbox_config, -) +from backend.secuscan.sandbox_executor import resolve_sandbox_config, classify_memory_violation from backend.secuscan.models import SandboxConfig -# --------------------------------------------------------------------------- -# classify_memory_violation -# --------------------------------------------------------------------------- - -class TestClassifyMemoryViolationExitCode: - """Exit-code based memory violation detection.""" - - def test_sigsegv_exit_code_negative_11_returns_true(self): - """Exit code -11 (SIGSEGV) always indicates memory corruption.""" - assert classify_memory_violation( - exit_code=-11, stderr_text="", rss_bytes=0, limit_bytes=100_000_000 - ) is True - - def test_sigsegv_exit_code_139_returns_true(self): - """Exit code 139 (128+11 = SIGSEGV on Linux) always indicates memory corruption.""" - assert classify_memory_violation( - exit_code=139, stderr_text="", rss_bytes=0, limit_bytes=100_000_000 - ) is True - - def test_exit_code_0_without_memory_signal_returns_false(self): - """Exit code 0 without memory signal in stderr returns False.""" - assert classify_memory_violation( - exit_code=0, stderr_text="", rss_bytes=0, limit_bytes=100_000_000 - ) is False - - def test_nonzero_exit_without_memory_signals_returns_false(self): - """Non-zero exits without memory signals return False.""" - assert classify_memory_violation( - exit_code=1, stderr_text=" segmentation fault", rss_bytes=0, limit_bytes=100_000_000 - ) is False - - -class TestClassifyMemoryViolationStderr: - """Stderr-based memory violation detection.""" - - def test_memoryerror_in_stderr_returns_true(self): - """Python MemoryError in stderr indicates OOM.""" - assert classify_memory_violation( - exit_code=1, stderr_text="MemoryError: cannot allocate", rss_bytes=0, limit_bytes=100_000_000 - ) is True - - def test_cannot_allocate_in_stderr_returns_true(self): - """System 'Cannot allocate memory' message in stderr indicates OOM.""" - assert classify_memory_violation( - exit_code=1, stderr_text="error: Cannot allocate memory", rss_bytes=0, limit_bytes=100_000_000 - ) is True - - def test_empty_stderr_with_nonzero_exit_returns_false(self): - """Non-zero exit with no memory signal returns False (unless RSS threshold met).""" - assert classify_memory_violation( - exit_code=42, stderr_text="some unrelated error", rss_bytes=0, limit_bytes=100_000_000 - ) is False - - -class TestClassifyMemoryViolationRSS: - """RSS-threshold based memory violation detection.""" - - def test_rss_at_95_percent_with_nonzero_exit_returns_true(self): - """RSS >= 95% of limit with non-zero exit is classified as OOM.""" - limit = 100_000_000 # 100 MB - rss = limit * 95 // 100 # exactly 95% - assert classify_memory_violation( - exit_code=1, stderr_text="", rss_bytes=rss, limit_bytes=limit - ) is True - - def test_rss_at_96_percent_with_nonzero_exit_returns_true(self): - """RSS well above 95% threshold with non-zero exit is classified as OOM.""" - limit = 100_000_000 - rss = limit # exactly at limit - assert classify_memory_violation( - exit_code=1, stderr_text="", rss_bytes=rss, limit_bytes=limit - ) is True - - def test_rss_below_95_percent_with_nonzero_exit_returns_false(self): - """RSS below 95% threshold with non-zero exit returns False.""" - limit = 100_000_000 - rss = limit * 94 // 100 # 94% - assert classify_memory_violation( - exit_code=1, stderr_text="", rss_bytes=rss, limit_bytes=limit - ) is False - - def test_rss_threshold_ignored_on_successful_exit(self): - """RSS threshold is only checked when exit_code is non-zero.""" - limit = 100_000_000 - rss = limit * 99 // 100 # 99% — but exit is 0 - assert classify_memory_violation( - exit_code=0, stderr_text="", rss_bytes=rss, limit_bytes=limit - ) is False - - -# --------------------------------------------------------------------------- -# resolve_sandbox_config -# --------------------------------------------------------------------------- - -def test_resolve_sandbox_config_with_no_override(): - """When plugin_sandbox is None, returns a config from global settings.""" - from backend.secuscan import config as config_module - - mock_settings = MagicMock() - mock_settings.sandbox_timeout = 120 - mock_settings.sandbox_memory_mb = 512 - mock_settings.sandbox_max_output_bytes = 5_000_000 - mock_settings.sandbox_allow_network = True - - original = config_module.settings - config_module.settings = mock_settings - try: - config = resolve_sandbox_config(plugin_sandbox=None) - finally: - config_module.settings = original - - assert config.timeout_seconds == 120 - assert config.max_memory_mb == 512 - assert config.allow_network is True - - -def test_resolve_sandbox_config_partial_override(): - """Partial per-plugin override only changes the specified fields.""" - from backend.secuscan import config as config_module - - mock_settings = MagicMock() - mock_settings.sandbox_timeout = 300 - mock_settings.sandbox_memory_mb = 512 - mock_settings.sandbox_max_output_bytes = 5_000_000 - mock_settings.sandbox_allow_network = True - - plugin_override = SandboxConfig(timeout_seconds=60) - - original = config_module.settings - config_module.settings = mock_settings - try: - config = resolve_sandbox_config(plugin_sandbox=plugin_override) - finally: - config_module.settings = original - - assert config.timeout_seconds == 60 - assert config.max_memory_mb == 512 - assert config.allow_network is True - - -def test_resolve_sandbox_config_full_override(): - """Full per-plugin override replaces all global defaults.""" - from backend.secuscan import config as config_module - - mock_settings = MagicMock() - mock_settings.sandbox_timeout = 300 - mock_settings.sandbox_memory_mb = 512 - mock_settings.sandbox_max_output_bytes = 5_000_000 - mock_settings.sandbox_allow_network = True - - plugin_override = SandboxConfig( - timeout_seconds=30, - max_memory_mb=256, - max_output_bytes=1_000_000, - allow_network=False, - ) - - original = config_module.settings - config_module.settings = mock_settings - try: - config = resolve_sandbox_config(plugin_sandbox=plugin_override) - finally: - config_module.settings = original - - assert config.timeout_seconds == 30 - assert config.max_memory_mb == 256 - assert config.max_output_bytes == 1_000_000 - assert config.allow_network is False +class TestResolveSandboxConfig: + def test_returns_defaults_when_no_override(self): + """resolve_sandbox_config returns global settings when plugin_sandbox is None.""" + result = resolve_sandbox_config(None) + assert isinstance(result, SandboxConfig) + # Verify it is a real SandboxConfig (not None) + assert result is not None + + def test_applies_timeout_override(self): + """resolve_sandbox_config overrides timeout when plugin_sandbox provides one.""" + override = SandboxConfig(timeout_seconds=120) + result = resolve_sandbox_config(override) + assert result.timeout_seconds == 120 + + def test_applies_memory_override(self): + """resolve_sandbox_config overrides max_memory_mb when plugin_sandbox provides one.""" + override = SandboxConfig(max_memory_mb=512) + result = resolve_sandbox_config(override) + assert result.max_memory_mb == 512 + + def test_applies_network_override(self): + """resolve_sandbox_config overrides allow_network when plugin_sandbox provides one.""" + override = SandboxConfig(allow_network=False) + result = resolve_sandbox_config(override) + assert result.allow_network is False + + +class TestClassifyMemoryViolation: + def test_sigsegv_exit_code_negative_11(self): + """classify_memory_violation returns True for exit code -11.""" + assert classify_memory_violation(-11, "", 0, 0) is True + + def test_sigsegv_exit_code_139(self): + """classify_memory_violation returns True for exit code 139.""" + assert classify_memory_violation(139, "", 0, 0) is True + + def test_memory_error_in_stderr(self): + """classify_memory_violation returns True when stderr contains MemoryError.""" + assert classify_memory_violation(1, "Python: MemoryError", 0, 0) is True + + def test_cannot_allocate_in_stderr(self): + """classify_memory_violation returns True when stderr contains 'Cannot allocate memory'.""" + assert classify_memory_violation(1, "error: Cannot allocate memory", 0, 0) is True + + def test_rss_near_limit_with_nonzero_exit(self): + """classify_memory_violation returns True when RSS >= 95% of limit and exit != 0.""" + assert classify_memory_violation(1, "", 950, 1000) is True + + def test_rss_near_limit_with_zero_exit(self): + """classify_memory_violation returns False when exit code is 0, even if RSS near limit.""" + assert classify_memory_violation(0, "", 950, 1000) is False + + def test_normal_exit_returns_false(self): + """classify_memory_violation returns False for normal exit with no memory indicators.""" + assert classify_memory_violation(0, "", 100, 1000) is False diff --git a/testing/backend/unit/test_saved_views.py b/testing/backend/unit/test_saved_views.py index b196e46b2..f8780a7ab 100644 --- a/testing/backend/unit/test_saved_views.py +++ b/testing/backend/unit/test_saved_views.py @@ -361,84 +361,4 @@ async def test_migration_failure_raises_runtime_error(tmp_path): finally: if db and db._connection: await db.disconnect() - broken.unlink(missing_ok=True) - - -# ─── FilterPreset model validators ───────────────────────────────────────────── - -from backend.secuscan.saved_views import FilterPreset, SavedViewCreate -from pydantic import ValidationError - - -class TestFilterPresetDefaults: - def test_default_severity_is_all(self): - preset = FilterPreset() - assert preset.severity == "all" - - def test_default_sort_mode_is_severity(self): - preset = FilterPreset() - assert preset.sortMode == "severity" - - def test_default_scanner_is_all(self): - preset = FilterPreset() - assert preset.scanner == "all" - - -class TestFilterPresetValidateSortMode: - def test_valid_sort_modes(self): - for mode in ("severity", "newest", "oldest", "target"): - preset = FilterPreset(sortMode=mode) - assert preset.sortMode == mode - - def test_invalid_sort_mode_raises(self): - with pytest.raises(ValidationError) as exc_info: - FilterPreset(sortMode="invalid_mode") - assert "sortMode must be one of" in str(exc_info.value) - - -class TestFilterPresetValidateSeverity: - def test_valid_severities(self): - for sev in ("all", "critical", "high", "medium", "low", "info"): - preset = FilterPreset(severity=sev) - assert preset.severity == sev - - def test_invalid_severity_raises(self): - with pytest.raises(ValidationError) as exc_info: - FilterPreset(severity="dangerous") - assert "severity must be one of" in str(exc_info.value) - - -# ─── SavedViewCreate model validators ────────────────────────────────────────── - -class TestSavedViewCreateStripName: - def test_valid_name_passes(self): - sv = SavedViewCreate(name="My View", filter_json='{"severity":"all"}') - assert sv.name == "My View" - - def test_strips_leading_trailing_whitespace(self): - sv = SavedViewCreate(name=" trimmed ", filter_json='{"severity":"all"}') - assert sv.name == "trimmed" - - def test_blank_name_raises(self): - with pytest.raises(ValidationError) as exc_info: - SavedViewCreate(name=" ", filter_json='{"severity":"all"}') - assert "name cannot be blank" in str(exc_info.value) - - def test_empty_string_name_raises(self): - with pytest.raises(ValidationError) as exc_info: - SavedViewCreate(name="", filter_json='{"severity":"all"}') - # Pydantic's min_length=1 constraint fires before field_validator, - # raising string_too_short. - assert "string_too_short" in str(exc_info.value) - - -class TestSavedViewCreateValidateFilterJson: - def test_valid_json_passes(self): - sv = SavedViewCreate(name="v", filter_json='{"severity":"critical"}') - assert sv.filter_json == '{"severity":"critical"}' - - def test_malformed_json_raises(self): - with pytest.raises(ValidationError) as exc_info: - SavedViewCreate(name="v", filter_json="not json") - # pydantic raises an error for invalid JSON in field_validator - assert "validation error" in str(exc_info.value).lower() + broken.unlink(missing_ok=True) \ No newline at end of file From f86b6a2cf274073cc175e38e79f57004fa9b8bcb Mon Sep 17 00:00:00 2001 From: Utkarsh Singh Date: Thu, 25 Jun 2026 05:48:25 +0530 Subject: [PATCH 2/2] Revert "fix(#1138): fix SSE EventSource leak and polling race condition (duplicate, conflicts resolved)"