diff --git a/backend/README.md b/backend/README.md index da0db26..077b175 100644 --- a/backend/README.md +++ b/backend/README.md @@ -114,6 +114,7 @@ Environment variables: - Response: `{"status": "received", "signal_id": ""}` **GET /signals** - List and filter signals +- Authentication: `X-API-Key` header (required) - Query parameters: - `severity`: Filter by severity (critical, high, medium, low, info) - `status`: Filter by status (open, acknowledged, resolved, or 'all'; default: 'open') @@ -123,6 +124,7 @@ Environment variables: - Response: `{"count": N, "signals": [...]}` **PATCH /signals/{signal_id}/status** - Update signal status +- Authentication: `X-API-Key` header (required) - Body: `{"status": "open" | "acknowledged" | "resolved"}` - Response: `{"signal_id": "", "status": ""}` @@ -143,6 +145,7 @@ Environment variables: - Response: `{"status": "ok", "agent_id": ""}` **GET /agents** - List agents with latest heartbeats +- Authentication: `X-API-Key` header (required) - Query parameters: - `since`: ISO timestamp to filter after (default: last 10 minutes) - `limit`: Maximum results (default: 200, max: 2000) @@ -151,13 +154,17 @@ Environment variables: ### Monitoring **GET /stats** - Get signal statistics +- Authentication: `X-API-Key` header (required) - Response: Counts by severity, host, and rule **GET /health** - Health check endpoint +- No authentication, so monitoring can poll it - Response: `{"status": "healthy", "timestamp": ""}` **GET /ui** - Web interface (if static/ directory exists) - Interactive signal management and agent monitoring dashboard +- Prompts for the API key on first load and keeps it in `sessionStorage` + for the lifetime of the tab **GET /** - API information and available endpoints @@ -170,6 +177,9 @@ Once running, visit: ## Security - API key authentication with constant-time comparison (prevents timing attacks) + on every endpoint that reads or mutates signal data. `/health` is deliberately + public; the static assets under `/ui` are not sensitive on their own, and the + data they render is gated by the API. - Minimum API key length: 16 characters - Context size limit: 100KB per signal - Connection timeout: 5 seconds diff --git a/backend/backend.py b/backend/backend.py index ecc5adf..716799a 100644 --- a/backend/backend.py +++ b/backend/backend.py @@ -9,7 +9,7 @@ uvicorn backend:app --host 0.0.0.0 --port 8443 """ -from fastapi import FastAPI, Header, HTTPException, Query +from fastapi import Depends, FastAPI, Header, HTTPException, Query from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, field_validator from contextlib import asynccontextmanager @@ -113,6 +113,17 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Santamon Backend", version="v0.1", lifespan=lifespan) + +async def require_api_key(x_api_key: str = Header(None, alias="X-API-Key")): + """ + Authenticate a request via the X-API-Key header. + + Applied to every endpoint that reads or mutates signal data. Uses a + constant-time comparison to avoid leaking the key through timing. + """ + if not x_api_key or not secrets.compare_digest(x_api_key, API_KEY): + raise HTTPException(status_code=401, detail="Invalid API key") + # Serve the web UI when the static assets are available STATIC_DIR = BASE_DIR / "static" if STATIC_DIR.exists(): @@ -165,20 +176,13 @@ class Heartbeat(BaseModel): uptime_seconds: Optional[float] = None -@app.post("/agents/heartbeat") -async def heartbeat( - hb: Heartbeat, - x_api_key: str = Header(None, alias="X-API-Key") -): +@app.post("/agents/heartbeat", dependencies=[Depends(require_api_key)]) +async def heartbeat(hb: Heartbeat): """ Receive agent heartbeat for health monitoring Authentication via X-API-Key header """ - # Use constant-time comparison to prevent timing attacks - if not x_api_key or not secrets.compare_digest(x_api_key, API_KEY): - raise HTTPException(status_code=401, detail="Invalid API key") - conn = sqlite3.connect(DB_PATH, timeout=5.0) try: conn.execute( @@ -218,7 +222,7 @@ def validate_status(cls, v): return v -@app.patch("/signals/{signal_id}/status") +@app.patch("/signals/{signal_id}/status", dependencies=[Depends(require_api_key)]) async def update_signal_status(signal_id: str, update: StatusUpdate): """Update status of a signal (open, acknowledged, resolved).""" conn = sqlite3.connect(DB_PATH, timeout=5.0) @@ -240,20 +244,13 @@ async def update_signal_status(signal_id: str, update: StatusUpdate): conn.close() -@app.post("/ingest") -async def ingest( - signal: Signal, - x_api_key: str = Header(None, alias="X-API-Key") -): +@app.post("/ingest", dependencies=[Depends(require_api_key)]) +async def ingest(signal: Signal): """ Ingest a security signal from santamon agent Authentication via X-API-Key header """ - # Use constant-time comparison to prevent timing attacks - if not x_api_key or not secrets.compare_digest(x_api_key, API_KEY): - raise HTTPException(status_code=401, detail="Invalid API key") - # Use connection with timeout conn = sqlite3.connect(DB_PATH, timeout=5.0) try: @@ -306,7 +303,7 @@ async def ingest( conn.close() -@app.get("/signals") +@app.get("/signals", dependencies=[Depends(require_api_key)]) async def list_signals( since: Optional[str] = Query(None, description="ISO timestamp to filter signals after"), severity: Optional[str] = Query(None, description="Filter by severity"), @@ -369,7 +366,7 @@ async def list_signals( conn.close() -@app.get("/stats") +@app.get("/stats", dependencies=[Depends(require_api_key)]) async def stats(): """ Get database statistics @@ -435,7 +432,7 @@ async def health(): return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()} -@app.get("/agents") +@app.get("/agents", dependencies=[Depends(require_api_key)]) async def list_agents( since: Optional[str] = Query(None, description="ISO timestamp to filter heartbeats after"), limit: int = Query(200, ge=1, le=2000, description="Maximum number of results") diff --git a/backend/static/index.html b/backend/static/index.html index 41bbfd8..ef61382 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -1147,6 +1147,39 @@

Signals Console

return params; } + // The API requires an X-API-Key header on every data endpoint. Keep the + // key in sessionStorage so it lives only for the duration of the tab, + // and prompt for it on first use. + const API_KEY_STORAGE = 'santamon-api-key'; + + function getApiKey() { + let key = sessionStorage.getItem(API_KEY_STORAGE); + if (!key) { + key = window.prompt('Santamon API key'); + if (key) { + sessionStorage.setItem(API_KEY_STORAGE, key); + } + } + return key || ''; + } + + function clearApiKey() { + sessionStorage.removeItem(API_KEY_STORAGE); + } + + // fetch() wrapper that attaches the API key and clears a rejected one so + // the next call re-prompts instead of looping on 401. + async function apiFetch(url, options = {}) { + const headers = Object.assign({}, options.headers, { + 'X-API-Key': getApiKey() + }); + const response = await fetch(url, Object.assign({}, options, { headers })); + if (response.status === 401) { + clearApiKey(); + } + return response; + } + async function loadDashboard(showSpinner = true) { const container = document.getElementById('signals-container'); const loadingBar = document.getElementById('loading-bar'); @@ -1166,11 +1199,14 @@

Signals Console

try { const params = buildQueryParams(); const [signalsResponse, statsResponse, hostsResponse] = await Promise.all([ - fetch(`/signals?${params.toString()}`), - fetch('/stats').catch(() => null), - fetch('/agents').catch(() => null) + apiFetch(`/signals?${params.toString()}`), + apiFetch('/stats').catch(() => null), + apiFetch('/agents').catch(() => null) ]); + if (signalsResponse.status === 401) { + throw new Error('Invalid or missing API key - reload to re-enter it'); + } if (!signalsResponse.ok) { throw new Error('Failed to load signals'); } @@ -1557,15 +1593,20 @@

Signals Console

async function updateSignalStatus(signalId, status) { try { - await fetch(`/signals/${signalId}/status`, { + const response = await apiFetch(`/signals/${signalId}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }); + if (!response.ok) { + throw new Error(response.status === 401 + ? 'Invalid or missing API key' + : `Server returned ${response.status}`); + } loadDashboard(false); } catch (e) { console.error('Failed to update status', e); - alert('Could not update signal status'); + alert(`Could not update signal status: ${e.message}`); } } diff --git a/backend/test_backend.py b/backend/test_backend.py index 90cbec1..87f5848 100644 --- a/backend/test_backend.py +++ b/backend/test_backend.py @@ -5,13 +5,17 @@ from fastapi.testclient import TestClient +# Must be at least MIN_API_KEY_LENGTH (16) characters or the module +# refuses to import. +TEST_API_KEY = "test-api-key-0123456789" + def _create_test_client(tmp_path): db_path = Path(tmp_path) / "signals.db" db_path.parent.mkdir(parents=True, exist_ok=True) os.environ["SANTAMON_DB_PATH"] = str(db_path) - os.environ["SANTAMON_API_KEY"] = "test-api-key" + os.environ["SANTAMON_API_KEY"] = TEST_API_KEY # Ensure a fresh module load with the new environment variables sys.modules.pop("backend.backend", None) @@ -20,6 +24,99 @@ def _create_test_client(tmp_path): return backend_module +SIGNAL_PAYLOAD = { + "signal_id": "signal-auth-1", + "ts": "2024-01-01T00:00:00Z", + "host_id": "host-1", + "rule_id": "rule-1", + "severity": "critical", + "title": "Credential theft detected", + "tags": ["T1555"], + "context": {"actor_path": "/tmp/evil"}, +} + + +def test_endpoints_reject_missing_api_key(tmp_path): + """Every data endpoint must require the API key.""" + backend_module = _create_test_client(tmp_path) + + with TestClient(backend_module.app) as client: + requests = [ + ("post", "/ingest", {"json": SIGNAL_PAYLOAD}), + ("post", "/agents/heartbeat", {"json": { + "agent_id": "host-1", + "timestamp": "2024-01-01T00:00:00Z", + "version": "0.1.0", + "os_version": "15.2", + }}), + ("get", "/signals", {}), + ("get", "/stats", {}), + ("get", "/agents", {}), + ("patch", "/signals/signal-auth-1/status", {"json": {"status": "resolved"}}), + ] + + for method, path, kwargs in requests: + response = getattr(client, method)(path, **kwargs) + assert response.status_code == 401, ( + f"{method.upper()} {path} returned {response.status_code}, expected 401" + ) + + +def test_endpoints_reject_wrong_api_key(tmp_path): + backend_module = _create_test_client(tmp_path) + headers = {"X-API-Key": "not-the-right-key"} + + with TestClient(backend_module.app) as client: + assert client.get("/signals", headers=headers).status_code == 401 + assert client.get("/stats", headers=headers).status_code == 401 + assert client.get("/agents", headers=headers).status_code == 401 + assert client.post("/ingest", json=SIGNAL_PAYLOAD, headers=headers).status_code == 401 + + +def test_signal_cannot_be_resolved_without_api_key(tmp_path): + """ + An unauthenticated PATCH must not be able to clear a signal out of the + console's default (status=open) view. + """ + backend_module = _create_test_client(tmp_path) + headers = {"X-API-Key": TEST_API_KEY} + + with TestClient(backend_module.app) as client: + assert client.post("/ingest", json=SIGNAL_PAYLOAD, headers=headers).status_code == 200 + + # Unauthenticated attempt to silence the alert + assert client.patch( + "/signals/signal-auth-1/status", json={"status": "resolved"} + ).status_code == 401 + + # The signal is still visible to an authenticated operator + open_signals = client.get("/signals?status=open", headers=headers).json() + assert open_signals["count"] == 1 + assert open_signals["signals"][0]["signal_id"] == "signal-auth-1" + + +def test_authenticated_requests_succeed(tmp_path): + backend_module = _create_test_client(tmp_path) + headers = {"X-API-Key": TEST_API_KEY} + + with TestClient(backend_module.app) as client: + assert client.post("/ingest", json=SIGNAL_PAYLOAD, headers=headers).status_code == 200 + assert client.get("/signals", headers=headers).status_code == 200 + assert client.get("/stats", headers=headers).status_code == 200 + assert client.get("/agents", headers=headers).status_code == 200 + assert client.patch( + "/signals/signal-auth-1/status", json={"status": "resolved"}, headers=headers + ).status_code == 200 + + +def test_health_remains_public(tmp_path): + """/health is intentionally unauthenticated so monitoring can poll it.""" + backend_module = _create_test_client(tmp_path) + + with TestClient(backend_module.app) as client: + assert client.get("/health").status_code == 200 + + def test_ingest_duplicate_flag(tmp_path): backend_module = _create_test_client(tmp_path) @@ -34,7 +131,7 @@ def test_ingest_duplicate_flag(tmp_path): "context": {"example": True}, } - headers = {"X-API-Key": "test-api-key"} + headers = {"X-API-Key": TEST_API_KEY} with TestClient(backend_module.app) as client: first_response = client.post("/ingest", json=payload, headers=headers)