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
10 changes: 10 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Environment variables:
- Response: `{"status": "received", "signal_id": "<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')
Expand All @@ -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": "<id>", "status": "<status>"}`

Expand All @@ -143,6 +145,7 @@ Environment variables:
- Response: `{"status": "ok", "agent_id": "<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)
Expand All @@ -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": "<iso>"}`

**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

Expand All @@ -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
Expand Down
43 changes: 20 additions & 23 deletions backend/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
51 changes: 46 additions & 5 deletions backend/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,39 @@ <h1>Signals Console</h1>
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');
Expand All @@ -1166,11 +1199,14 @@ <h1>Signals Console</h1>
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');
}
Expand Down Expand Up @@ -1557,15 +1593,20 @@ <h1>Signals Console</h1>

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}`);
}
}

Expand Down
101 changes: 99 additions & 2 deletions backend/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand All @@ -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)
Expand Down