Palisade is a multi-tenant attack-surface monitor: a FastAPI control plane (Postgres + Redis), a React web UI, and a Go agent that scans hosts and reports findings. The control plane holds every tenant's assets, findings, the internal mTLS CA private key, and the catalog-signing key, so it is the asset that matters most.
This document is the threat model and the operator hardening checklist. The
controls below were added in the security-hardening pass; see
control-plane/app/preflight.py for the startup gate that enforces several of
them.
- Internet → API. A Cloudflare Tunnel publishes
api.trypalisade.dev → api:8000(control-plane/deploy/cloudflared/config.yml). The entire/v1/*surface — including agent enroll/heartbeat/ingest — is internet-reachable. Postgres and Redis are not tunneled; the home/Proxmox overlay (docker-compose.override.yml) additionally unpublishes the API's host port (tunnel-only reach) and pins the data tier to an egress-lessinternalnetwork. Seecontrol-plane/deploy/README.md→ Blast radius & isolation. - Web → API. Session bearer token, now also delivered as an httpOnly cookie (see below).
- Agent → API. mTLS client cert (preferred) or a bearer
agent_secret. - API → Postgres. The app connects as the database role and is bound by Row-Level Security (see below). DB credentials never leave the control plane.
| Surface | Risk | Control |
|---|---|---|
POST /v1/detections/draft (cve_url) |
Authenticated SSRF to cloud metadata / internal services; prompt injection | app/netguard.py: scheme allowlist, resolve + reject private/loopback/link-local/reserved IPs, validated-IP pinning into the connection, no redirect following. Returns empty on any block. |
Agent findings ingest (POST /v1/scans/{id}/findings) |
A compromised agent attaches findings to another tenant's scan/assets, or overwrites another tenant's finding via a shared fingerprint | scan_id and every asset_id are bound to the agent's org at the boundary (routers/scans.py); dedupe is org-scoped (ingest.py); fingerprints are unique per org (uq_finding_org_fingerprint). |
| Cross-tenant DB access via an app-layer bug | RLS was only ENABLEd, which Postgres does not apply to the table owner or a superuser — and the app connects as the superuser palisade, so RLS was bypassed entirely; isolation rode on Python WHERE org_id filters |
Migration 0011 adds FORCE ROW LEVEL SECURITY; migration 0012 adds a NOLOGIN, NOSUPERUSER palisade_app role and the app SET LOCAL ROLEs to it before every tenant query (tenancy._set_rls_org), so the app.current_org_id GUC becomes a real DB-level backstop. Proven in CI (app/rls_postgres_test.py). |
| Catalog bundle forgery | With no signing key set, the server signed with the public demo seed; agents pin the matching public key, so anyone could forge a trusted bundle | The startup preflight refuses to boot a production deployment whose PALISADE_SIGNING_KEY is unset or the demo seed. |
| Stolen DB dump | The internal CA private key was stored unencrypted, letting an attacker mint trusted agent certs; evidence was plaintext | CA key and evidence are sealed with AES-256-GCM under PALISADE_EVIDENCE_KEK (app/encryption.py, enc:v1: format), with transparent plaintext fallback for keyless dev. |
Stolen bearer agent_secret |
Authenticates as the agent over plaintext | PALISADE_REQUIRE_MTLS defaults on in production; the bearer fallback is rejected when a client cert is required. |
| Default credentials | palisade:palisade, demo login palisade, demo agent secret |
The preflight refuses to boot production while any public default is in place. |
| XSS stealing the session token | Token in localStorage was exfiltratable |
Token is delivered as an httpOnly, Secure, SameSite=Lax cookie; the web app keeps it in memory only and relies on the cookie across reloads. Header bearer still works for agents/tests. |
| Unbounded perimeter scanning | Empty scope allowlist meant scan-all | Empty allowlist is deny-all in production, allow-all only for dev/demo (perimeter.py). |
On a Postgres deployment that has not set PALISADE_ALLOW_INSECURE_DEFAULTS,
the control plane refuses to start while any of these public defaults remain
(app/preflight.py):
DATABASE_URLusingpalisade:palisadePALISADE_SIGNING_KEYunset or equal to the public demo seedPALISADE_DEMO_USER_PASSWORDstillpalisadePALISADE_EVIDENCE_KEKunset
SQLite (dev/test) and the public demo (which sets
PALISADE_ALLOW_INSECURE_DEFAULTS=1) downgrade these to logged warnings so the
one-command local workflow and the live demo keep working.
The public demo is intentionally insecure. It runs on default credentials and the public signing key by design. Never point a real tenant at it.
Set these before exposing an instance (see control-plane/.env.example):
- Unset
PALISADE_ALLOW_INSECURE_DEFAULTS(or set it to0). DATABASE_URL— unique DB password (openssl rand -hex 24).PALISADE_SIGNING_KEY— fresh Ed25519 seed (python -c "import os,base64;print(base64.b64encode(os.urandom(32)).decode())") and pin its public key on agents viaPALISADE_CATALOG_PUBKEY.PALISADE_EVIDENCE_KEK— base64 32-byte key; seals evidence and the CA key.PALISADE_DEMO_USER_PASSWORD— change it, or remove the demo user.PALISADE_REQUIRE_MTLS— left on by default in production; keep it on and terminate TLS at a proxy that verifies the client cert against the CA.PALISADE_PERIMETER_SCOPE_ALLOWLIST— confirm in-scope hosts before any control-plane probe leaves the box.
- The evidence KEK is effectively non-rotatable for the CA key.
PALISADE_EVIDENCE_KEKseals the internal CA private key (enc:v1:), andencryption.open_secretdoes not fall back on a decrypt failure (unlike evidence reads). Rotating or removing the KEK without first re-wrappingcert_authority.key_pemwill hard-fail agent enrollment (mtls.issue_client_certraises). To rotate: re-seal the CA key under the new KEK in the same step, or accept that the CA must be re-issued. - The web origin must be a
trypalisade.devsubdomain. The session cookie is host-only,Secure,SameSite=Lax. With the web app atapp.trypalisade.dev/trypalisade.devand the API atapi.trypalisade.dev, requests are same-site and the cookie flows. If the web app is ever served from a raw*.pages.devpreview origin, it becomes cross-site andSameSite=Laxdrops the cookie on XHR — login works in-tab but silently fails to rehydrate on reload. Either keep the web origin ontrypalisade.dev, or switch the cookie toSameSite=None; Securewith a strict CORS allowlist if a cross-site origin must be supported.
- Connect (not just
SET ROLE) as the non-superuser role. The app drops topalisade_appper transaction, which makes RLS bind. Connecting as a dedicated least-privilege role for the whole session (separate credentials for themigratestep vs the app) would be stronger still, and is the natural next step now that the role and grants exist. - CSRF. With cookie auth,
SameSite=Lax+ header-precedence cover the common cases; an explicit CSRF token on mutating endpoints would harden it further. - SSRF TOCTOU. Mitigated by pinning the validated IP into the connection; the narrow residual is resolver behavior between validation and connect.