Skip to content

backend: require the API key on all signal endpoints - #6

Open
cocopollo wants to merge 1 commit into
0x4D31:mainfrom
cocopollo:fix/backend-auth
Open

backend: require the API key on all signal endpoints#6
cocopollo wants to merge 1 commit into
0x4D31:mainfrom
cocopollo:fix/backend-auth

Conversation

@cocopollo

Copy link
Copy Markdown

Problem

backend.py checks X-API-Key on POST /ingest and POST /agents/heartbeat. These four are not checked at all:

  • GET /signals
  • GET /stats
  • GET /agents
  • PATCH /signals/{signal_id}/status

Run directly, the server binds 0.0.0.0:8443 (L520). So anyone who can reach it reads every detection: host IDs, actor and target paths, hashes, argv, process trees.

The PATCH is the worse one. GET /signals defaults to status=open, and that's the view the console renders, so resolving a signal removes it from the operator's screen. Someone on a monitored endpoint can look up which of their actions tripped a rule and then clear those alerts, without the key.

That cuts against what SECURITY.md sets out as the goal:

Not perfect protection against determined root attackers, but resilience and visibility - if someone disables Santamon, it restarts quickly and you know about it.

Repro on current main

$ # ingest does enforce the key
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST $B/ingest \
    -H 'Content-Type: application/json' -d '{...}'
401

$ # seed a critical signal, with the key
$ curl -s -X POST $B/ingest -H "X-API-Key: $KEY" \
    -H 'Content-Type: application/json' -d '{...}'
{"status":"ok","id":"deadbeef01","duplicate":false}

$ # resolve it, no key
$ curl -s -X PATCH $B/signals/deadbeef01/status \
    -H 'Content-Type: application/json' -d '{"status":"resolved"}'
{"signal_id":"deadbeef01","status":"resolved"}

$ # gone from the console's default view
$ curl -s "$B/signals?status=open"
{"count":0,"signals":[]}

With this patch the PATCH returns 401 and the signal stays in status=open.

Changes

backend.py: pulled the existing constant-time check out into a require_api_key dependency and applied it to all six data endpoints. /ingest and /agents/heartbeat are switched over too, so there's one auth path instead of an inline check in some handlers and nothing in others. /health stays open for monitoring.

static/index.html: the UI needs to send the key now. It prompts on first load, keeps it in sessionStorage for the life of the tab, and attaches it via a small apiFetch wrapper. A 401 clears the stored key so a reload re-prompts. updateSignalStatus now checks the response too, which it wasn't doing, so a rejected update no longer looks like it worked.

test_backend.py: five tests covering missing key, wrong key, the resolve-without-key case above, the authenticated path, and /health staying public.

This also fixes the fixture. It set SANTAMON_API_KEY to "test-api-key", 12 chars, under the 16 char minimum enforced at import, so the module raised RuntimeError on collection and the backend suite couldn't run at all. There's no Python job in CI, so nothing caught it.

README.md: marked the newly gated endpoints, noted /health is intentionally public, described the UI prompt.

Testing

$ python -m pytest backend/test_backend.py -q
6 passed

Same command on main fails with RuntimeError: SANTAMON_API_KEY must be at least 16 characters long.

A couple of notes

sessionStorage plus a prompt() is the smallest thing that keeps the console working once the API is gated. If you'd rather it sat behind a reverse proxy or a real login, I'll drop the UI part and just gate the API.

Unrelated but nearby: run without cert.pem and it falls back to plain HTTP while still binding 0.0.0.0 (L516-520). It does warn, but the key goes over the wire in clear. Can open that separately if useful.

Only POST /ingest and POST /agents/heartbeat verified X-API-Key. These did
not:

  GET   /signals
  GET   /stats
  GET   /agents
  PATCH /signals/{signal_id}/status

backend.py binds 0.0.0.0:8443 when run directly, so anyone able to reach it
could read every detection and, via PATCH, mark signals resolved. Because
GET /signals defaults to status=open, which is what the console shows, a
resolved signal disappears from the operator's view.

Extract the existing constant-time check into a require_api_key dependency
and apply it to all six data endpoints, including the two that already had
it inline so there is a single auth path. /health stays public for
monitoring.

The web UI now prompts for the key on first load, keeps it in
sessionStorage for the lifetime of the tab, and sends it as X-API-Key on
every request; a 401 clears the stored key so the next load re-prompts.
updateSignalStatus also checks the response, which it previously ignored,
so a rejected update no longer looks like it succeeded.

Also fixes the test fixture: it set an API key of "test-api-key" (12
chars), below the 16-character minimum enforced at import, so the backend
suite could not run at all. CI has no Python job, so this went unnoticed.

Tests: 6 passed (5 new covering missing key, wrong key, the resolve-without-key
case, the authenticated happy path, and /health staying public).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant