Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions PLUGINS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 2 additions & 129 deletions backend/secuscan/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,141 +5,20 @@
Clients must supply it via:
- Authorization: Bearer <key>
- X-Api-Key: <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:
"""
Expand Down Expand Up @@ -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 <key>``
- ``X-Api-Key: <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,
Expand Down
8 changes: 0 additions & 8 deletions backend/secuscan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions backend/secuscan/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 4 additions & 93 deletions backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -19,21 +19,17 @@
)
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
from .saved_views import saved_views_router
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=[
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -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",
Expand All @@ -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(),
}

Expand Down Expand Up @@ -348,4 +259,4 @@ def main():
)

if __name__ == "__main__":
main()
main()
Loading
Loading