Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ SECUSCAN_ALLOW_LOOPBACK_SCANS=true
# The server refuses to start the vault if this is unset.
SECUSCAN_VAULT_KEY=replace-with-output-of-secrets.token_hex-32

# API key for authenticating all API requests (auto-generated on first startup if not set)
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECUSCAN_API_KEY=

# Plugin Security
# SECUSCAN_PLUGIN_SIGNATURE_KEY=replace-with-your-signing-key
# SECUSCAN_ENFORCE_PLUGIN_SIGNATURES=false
Expand Down
60 changes: 60 additions & 0 deletions backend/secuscan/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""
API key authentication dependency for SecuScan.

On first startup, if SECUSCAN_API_KEY is not set, a random key is generated,
written to data/api_key.txt, and logged once so the user can copy it.
Every non-health-check request must supply the key as:
- Authorization: Bearer <key> OR
- X-Api-Key: <key>
"""
from __future__ import annotations

import logging
import secrets
from functools import lru_cache
from pathlib import Path

from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

logger = logging.getLogger(__name__)

_bearer = HTTPBearer(auto_error=False)

_API_KEY_FILE = Path(__file__).resolve().parent.parent / "data" / "api_key.txt"


@lru_cache(maxsize=1)
def _get_active_key() -> str:
from .config import settings
if settings.api_key:
return settings.api_key
if _API_KEY_FILE.exists():
key = _API_KEY_FILE.read_text().strip()
if key:
return key
key = secrets.token_hex(32)
_API_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
_API_KEY_FILE.write_text(key)
logger.warning(
"No SECUSCAN_API_KEY set. Generated key saved to %s -- API KEY: %s",
_API_KEY_FILE, key,
)
return key


async def require_api_key(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> None:
active_key = _get_active_key()
if credentials and secrets.compare_digest(credentials.credentials, active_key):
return
x_api_key = request.headers.get("X-Api-Key", "")
if x_api_key and secrets.compare_digest(x_api_key, active_key):
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key.",
headers={"WWW-Authenticate": "Bearer"},
)
1 change: 1 addition & 0 deletions backend/secuscan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class Settings(BaseSettings):
plugin_signature_key: Optional[str] = None
enforce_plugin_signatures: bool = False
vault_key: Optional[str] = None
api_key: Optional[str] = None

# Rate Limiting
max_concurrent_tasks: int = 3
Expand Down
5 changes: 3 additions & 2 deletions backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from contextlib import asynccontextmanager
from .request_middleware import RequestIDMiddleware

from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

Expand All @@ -18,6 +18,7 @@
from .database import init_db, db as global_db
from .plugins import init_plugins
from .routes import router
from .auth import require_api_key
from .workflows import scheduler


Expand Down Expand Up @@ -124,7 +125,7 @@ async def redirect_api_openapi():
app.add_middleware(RequestIDMiddleware)

# Include API routes
app.include_router(router)
app.include_router(router, dependencies=[Depends(require_api_key)])

# Health check endpoint
@app.get("/api/v1/health")
Expand Down
3 changes: 3 additions & 0 deletions testing/backend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ def setup_test_environment(monkeypatch):
monkeypatch.setattr(settings, "plugins_dir", str(repo_root / "plugins"))
monkeypatch.setattr(settings, "database_path", f"{temp_path}/test_secuscan.db")
monkeypatch.setattr(settings, "vault_key", "test-vault-key-for-unit-tests-only")
monkeypatch.setattr(settings, "api_key", "test-api-key-for-unit-tests-only")
from backend.secuscan.auth import _get_active_key
_get_active_key.cache_clear()

settings.ensure_directories()

Expand Down
Loading