Source paths below (
lib/,next.config.ts, ...) are relative toapps/control-plane/- seedocs/distributed-audit.mdfor the workspace conversion.docs/threat-model.md(once it exists) covers the distributed control-plane threat model.
Bot tokens (and Discord webhook URLs) are encrypted with AES-256-GCM
(lib/crypto.ts) using a 32-byte key from BOTFLEET_ENCRYPTION_KEY
(base64-encoded; generate with openssl rand -base64 32).
- Encryption/decryption only ever happens inside server-side code (route handlers, the seed script). Nothing client-side ever touches a plaintext or encrypted token.
- No API response includes
tokenEncryptedor a webhook'surlEncrypted- seelib/serialize.ts. There is no "masked" version returned either (e.g.sk-***1234); the field is simply never in the response shape. - Token rotation (
POST /api/admin/bots/:id/rotate-token) re-encrypts and overwrites in place; the audit log records that a rotation happened, not the token. - Nothing in this codebase calls
console.log/logs a decrypted secret. Grep fordecryptSecret(if you want to verify this yourself - every call site is insidelib/alerts/discord-webhook.ts(to actually POST to Discord) or a route that discards the value after use.
- Admin routes (
/admin/*pages,/api/admin/*): guarded byrequireAdmin()(lib/require-admin.ts), which checkssession.user.roleisadminorowner. API routes always return a JSON401/403- never a redirect - so a fetch call never silently receives an HTML login page. - Customer routes (
/api/customer/*): guarded byrequireCustomerSession()+loadOwnedBot()(lib/require-customer.ts).loadOwnedBot()returnsnull- and the route returns a generic 404 - both when a bot doesn't exist and when it exists but belongs to someone else, so bot IDs can't be enumerated to distinguish the two cases. - First-run admin bootstrap:
BOTFLEET_ADMIN_DISCORD_IDSis a comma-separated allowlist of Discord user IDs. The first time one of those users signs in,auth.ts'ssessioncallback promotes them toowner. Additional admins can be promoted afterward at/admin/users- only anownercan change roles there, and the last remaining owner can never be demoted (guarded server-side inPATCH /api/admin/users/:id, not just hidden in the UI).
See docs/agent-enrollment.md for the full flow. Summary of the security
properties:
- Enrollment tokens are single-use (atomically claimed, not read-then-write), expiring (30 min default), hashed at rest (SHA-256), and can be scoped to an environment/label set.
- Agent credentials are a disclosed, non-production placeholder: a bearer
secret, not mutual TLS.
lib/agents/credential.ts's doc comment explains exactly why and what a real mTLS upgrade would replace. Do not treat this as equivalent to a certificate-based identity for a production multi-tenant deployment. - An unauthenticated agent connection can only ever send one message type
(
agent.enroll) - every other message on an unauthenticated socket is dropped and logged bylib/agent-gateway/server.ts, never processed. - Every protocol message is replay-guarded by
messageId(@botfleet/protocol'sInMemoryReplayGuard) and schema-validated before any handler runs.
Set in next.config.ts for every response. script-src only allows
'unsafe-eval' in development (Next.js Fast Refresh needs it); production
builds get script-src 'self' with no eval.
GET /api/admin/security (rendered at /admin/security) computes a real
report from actual state - see lib/security-checks.ts. Every check
either reads an environment variable, queries the database, or reflects a
structural guarantee that's true by construction (e.g. "tokens encrypted
at rest" is true because there is no plaintext token column in the
schema). Nothing is a hardcoded score.
npm audit currently reports 2 moderate advisories, both in build/dev
tooling, not runtime app code:
@hono/node-server(via Prisma's localprisma devCLI, which this project doesn't use - we run against a real Postgres instance).postcss(bundled inside Next.js's build pipeline).
Both would require downgrading to a much older major version of Prisma or Next.js to "fix," which is a worse trade than the advisory itself. Revisit when upstream ships a patched release.
Please don't open a public issue for a security problem. Use GitHub's private vulnerability reporting (Security tab → "Report a vulnerability") on this repository.