Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
79 changes: 79 additions & 0 deletions backend/secuscan/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""
API key authentication for SecuScan backend.

A random key is generated at startup and written to <data_dir>/.api_key.
Clients must supply it via:
- Authorization: Bearer <key>
- X-Api-Key: <key>
"""

import os
import secrets
from pathlib import Path

from fastapi import Depends, HTTPException, Security, status
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer

_bearer_scheme = HTTPBearer(auto_error=False)
_api_key_header = APIKeyHeader(name="X-Api-Key", auto_error=False)

_api_key: str | None = None


def init_api_key(data_dir: str) -> str:
"""
Load the persisted API key, or generate and persist a new one.

Called once during application startup; the returned key is also stored in
the module-level ``_api_key`` variable so the FastAPI dependency can reach it.
"""
global _api_key
# Allow operators to redirect the key file via env var (e.g. Docker secrets).
custom_path = os.environ.get("SECUSCAN_API_KEY_FILE", "").strip()
key_file = Path(custom_path) if custom_path else Path(data_dir) / ".api_key"
if key_file.exists():
_api_key = key_file.read_text().strip()
else:
_api_key = secrets.token_hex(32)
key_file.parent.mkdir(parents=True, exist_ok=True)
key_file.write_text(_api_key)
key_file.chmod(0o600)
return _api_key


async def require_api_key(
bearer: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
x_api_key: str | None = Security(_api_key_header),
) -> str:
"""
FastAPI dependency — rejects requests that do not carry the correct API key.

Accepts the key in either:
- ``Authorization: Bearer <key>``
- ``X-Api-Key: <key>``
"""
if _api_key is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication service not initialised",
)

candidate: str | None = None
if bearer is not None:
candidate = bearer.credentials
elif x_api_key is not None:
candidate = x_api_key

if candidate is None or not secrets.compare_digest(candidate, _api_key):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
headers={"WWW-Authenticate": "Bearer"},
)

return candidate


def get_api_key() -> str | None:
"""Return the current API key, or None if not yet initialised."""
return _api_key
6 changes: 6 additions & 0 deletions backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from fastapi.staticfiles import StaticFiles

from .config import settings
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 .plugins import init_plugins
Expand Down Expand Up @@ -51,6 +52,10 @@ async def lifespan(app: FastAPI):
# Ensure directories exist
settings.ensure_directories()
logger.info("✓ Directories initialized")

# Initialize API key authentication
api_key = init_api_key(settings.data_dir)
logger.info("✓ API key authentication ready (key file: %s/.api_key)", settings.data_dir)

# Initialize database
await init_db(settings.database_path)
Expand Down Expand Up @@ -128,6 +133,7 @@ async def redirect_api_openapi():
app.include_router(router)
app.include_router(saved_views_router)


# Health check endpoint
@app.get("/api/v1/health")
async def health_check():
Expand Down
8 changes: 7 additions & 1 deletion backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ def _serialize_workflow(row: Dict[str, Any], queued_task_ids: Optional[List[str]


def is_filesystem_target(target: str) -> bool:
"""Best-effort detection for path-based targets that should bypass host validation."""
# Absolute or relative filesystem roots only — not CIDR notation (e.g. 8.8.8.8/32)
if target.startswith(("/", "./", "../", "~/")):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat slash-containing relative paths as filesystem targets

The new is_filesystem_target() logic no longer recognizes relative paths like artifacts/memdump.raw (it only accepts /, ./, ../, ~/, or Windows-drive prefixes), so these values now fall into validate_target() and are rejected as invalid hostnames. This regresses non-code plugins that accept file/directory targets because previously slash-containing paths bypassed host validation; now users must rewrite existing inputs to add ./.

Useful? React with 👍 / 👎.

return True
# Windows drive paths (C:\ or C:/)
"""
Return True only for genuine local filesystem paths.

Expand Down Expand Up @@ -121,10 +126,11 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str:
from .reporting import reporting
from .vault import VaultCrypto
from .workflows import scheduler
from .auth import require_api_key

from sse_starlette.sse import EventSourceResponse

router = APIRouter(prefix="/api/v1")
router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_api_key)])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude SSE stream route from global API-key dependency

Applying Depends(require_api_key) at router scope also protects /api/v1/task/{task_id}/stream, but the browser clients open that endpoint with native EventSource (frontend/src/api.ts and frontend/src/pages/TaskDetails.tsx), which cannot attach the required auth headers. As a result, scan progress streams will consistently fail with 401 in the UI after this change, even if non-stream API calls are later updated to send credentials.

Useful? React with 👍 / 👎.

SSE_RAW_OUTPUT_CHUNK_SIZE = 64 * 1024

_EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
Expand Down
84 changes: 84 additions & 0 deletions docs/api-authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# API Authentication

All `/api/v1/*` routes require a valid API key. The only unauthenticated endpoints
are `/` (API info) and `/api/v1/health` (health checks).

## How the key is generated

On first startup the backend generates a cryptographically random 64-character hex key,
writes it to `<data_dir>/.api_key` (mode `0600`), and prints it to the console:

```
✓ API key authentication ready (key file: backend/data/.api_key)
```

On every subsequent start the same key is loaded from the file.

To rotate the key, delete the file and restart the backend:

```bash
rm backend/data/.api_key
python -m secuscan # a new key is generated on startup
```

## Frontend / UI

The web UI does **not** fetch the key from the backend. You must configure it
manually once after starting the backend:

1. Read the key from the key file:
```bash
cat backend/data/.api_key
```
2. Open the SecuScan UI → **Settings** → **API Key** section.
3. Paste the key into the **Backend API Key** field and click **Save**.

The key is stored in the browser's `localStorage` under `secuscan_api_key` and
sent automatically on every subsequent API request via the `X-Api-Key` header.
No server-side session or cookie is involved — only the operator's browser retains
the key.

## External / scripted access

Read the key from the file and pass it in either of two header formats:

```bash
API_KEY=$(cat backend/data/.api_key)

# Option A — X-Api-Key header
curl -H "X-Api-Key: $API_KEY" http://localhost:8000/api/v1/plugins

# Option B — Bearer token
curl -H "Authorization: Bearer $API_KEY" http://localhost:8000/api/v1/plugins
```

## Environment variable override

Set `SECUSCAN_API_KEY_FILE` to point to a different key file path if you need to
store the key outside the default data directory:

```bash
export SECUSCAN_API_KEY_FILE=/run/secrets/secuscan_api_key
python -m secuscan
```

## Unauthenticated endpoints

| Path | Reason |
|---|---|
| `GET /` | API info / root |
| `GET /api/v1/health` | Health checks and monitoring |

All other `/api/v1/*` routes require a valid `X-Api-Key` or `Authorization: Bearer`
header. Requests without a valid key receive `HTTP 401`.

## Security considerations

- The key file is written with mode `0600` so only the process owner can read it.
- Key comparison uses `secrets.compare_digest` to prevent timing-oracle attacks.
- There is no unauthenticated endpoint that exposes the key over the network.
The only way to retrieve the key is to read the file from the filesystem where
the backend is running — which requires local access to that machine.
- If the backend is not yet initialised (key file missing and startup not complete),
protected routes return `HTTP 503` rather than `401` to distinguish between
an uninitialised service and a bad credential.
28 changes: 5 additions & 23 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
Expand Down
Loading
Loading