Report vulnerabilities privately through GitHub's advisory form:
https://github.com/trypando/pando/security/advisories/new
That form is private between you and the maintainers until an advisory is published. Do not open a public issue for a vulnerability, and do not report one in a pull request — a pull request that fixes a security bug describes the bug in public before anyone can upgrade.
The advisory form is the only reporting channel. It requires a GitHub account, which is a real cost for a reporter who does not have one, and it is the trade we are making: one channel that is definitely monitored beats a second one that might not be.
- The version or commit you tested, and how Pando was installed.
- Which component is affected — the proxy, the API, an adapter, the console, the CLI, the MCP server.
- What an attacker gains, and what access they need to start. "An authenticated user with
app.viewon one app can read another app's secrets" is actionable. "Secrets can be read" is not. - Steps to reproduce, and a proof of concept if you have one.
| Stage | Target |
|---|---|
| Acknowledgment that the report arrived | 3 business days |
| An initial assessment — is it a vulnerability, and how severe | 14 days |
| Fix released for a critical or high severity issue | 30 days from confirmation |
| Fix released for a medium or low severity issue | The next release |
Severity is assigned with CVSS v3.1. If a fix will take longer than the target above, we will say so in the advisory thread rather than let it go quiet.
Fixes are released together with a GitHub Security Advisory and a CVE where one applies, and are
listed in CHANGELOG.md. Reporters are credited in the advisory unless they ask not
to be.
In scope: anything in this repository — the server, proxy, adapters, console, CLI, MCP server,
migrations, and the shipped docker-compose.yml and Dockerfile.
Out of scope:
- Vulnerabilities in applications a user deploys onto Pando. Pando authenticates the caller and authorizes the request; it does not audit the app behind it.
- Vulnerabilities in a third-party dependency with no reachable path from Pando's own code. Report those upstream. If there is a reachable path, it is in scope — say which call reaches it.
- A finding that requires host root, physical access, or a host already under an attacker's control. Pando trusts the host it runs on.
- Missing hardening that has no exploit behind it. Send that as an issue or a pull request.
Only the most recent release receives security fixes. Fixes land on main and go out in the next
release; to receive one, upgrade to the release that contains it.
| Version | Supported |
|---|---|
| Most recent release | Yes |
| Anything older | No — upgrade |
The design is in docs/design/06-authorization-and-proxy.md;
this is the short version of what Pando relies on and what it does not claim.
One enforcement point. Every request to every app goes through Pando's proxy, which authenticates the caller and makes the authorization decision (R-023). There is no bypass for public apps or for websockets. A routing adapter puts traffic in front of that proxy and never points at a workload directly.
Two independent planes. Being able to use an app and being able to administer it are separate grants (R-029, R-070, R-071). Holding an administrative role does not make someone a user of every app, and using an app daily grants nothing administrative.
Two independent scopes. A grant is either app-scoped or installation-scoped, never both, and each
check refuses the other's verbs rather than evaluating them (R-080). An administrator holds no
app.* verb and is not an implicit owner of every app (R-087).
Headers are not trust. Inbound X-Pando-* headers are stripped unconditionally before a request
reaches an app (R-053), so a client cannot forge one. What an app can trust is the signed assertion
in X-Pando-Assertion, verified against the JWKS Pando publishes. Outbound pando_* cookies are
stripped before reaching an app (R-173), so Pando's session cookie is never exposed to a deployed
application.
Guessing is limited. After 10 wrong passwords for one username, or 50 from one client address, in 15 minutes, password sign-in is refused until the oldest of them is 15 minutes old, and the refusal says when to try again (R-404). An app's passcode allows 10 wrong guesses per visitor in 15 minutes (R-075a). These counts are kept in the database, so every replica shares them. Each failure and each refusal is in the audit log. Anonymous requests to an app are rate-limited per client address (R-075).
The audit log cannot be rewritten. The database role Pando runs as holds no UPDATE or DELETE
on audit_events (R-027). Neither Pando nor an adapter can alter the record after the fact.
Builds get no container runtime socket. BuildKit runs rootless in its own container, and an integration test asserts on that container's actual mount list (R-112). A build that could reach the runtime socket would be a container escape by design.
Secrets do not reach logs. Secret values are carried in a secret.Value type that renders
[redacted] in every marshaler it implements — String, GoString, Format, MarshalJSON,
MarshalText, and zap's MarshalLogObject (R-194).
Apps are isolated from each other. Each app runs on its own private network and publishes nothing to the host beyond the port Pando allocates it.
-
It trusts the host it runs on and the container runtime it drives. A compromised host is a compromised installation.
-
Isolation between apps is container-level, not a boundary between hostile tenants. An installation is designed to serve one organization (R-015); separate organizations run separate installations.
-
Its server speaks plain HTTP. TLS is terminated in front of it: by the edge Pando runs itself when that is turned on, which issues and renews certificates over ACME (R-169, R-174), or by a reverse proxy or load balancer the operator runs. On a public installation, reaching Pando without TLS in front exposes session cookies and bearer tokens.
Behind either one, set
PANDO_SERVER_EXTERNAL_URLto the address browsers use —https://pando.example.com. Turning on Pando's edge does not set it. Pando cannot tell an encrypted browser connection from an unencrypted one when every request reaches it over plain HTTP, so without this the session cookie is not markedSecureand one plaintext request to the hostname puts it on the wire. Pando does not infer this fromX-Forwarded-Proto, for the same reason it strips inboundX-Pando-*: any client that can reach it directly can set that header.The audit log records each request's client address. Behind either one, list its addresses in
PANDO_SERVER_TRUSTED_PROXIES(comma-separated CIDRs) and Pando readsX-Forwarded-Forfrom those peers only, taking the right-most address that is not itself a listed proxy. With the list empty, the default, the audit log records the proxy's own address. The list decides the audited address and the address rate limits are kept per, and nothing else: notSecure. Without it, every client behind the proxy shares one rate limit. A range covering most of the internet is refused at startup. -
It has no multi-factor authentication of its own. Accounts can sign in through an identity provider over OIDC or SAML 2.0, with SCIM provisioning (docs/identity-providers.md), and MFA is the provider's. Password sign-in to local accounts can be turned off once one is set up.
Pando does not implement cryptographic primitives. Everything below comes from the Go standard
library or golang.org/x/crypto.
| Use | Algorithm | Where |
|---|---|---|
| Password hashing | argon2id — 64 MiB, t=3, p=2, 16-byte salt, 32-byte key; parameters encoded in each hash so they can be raised without invalidating credentials | internal/hash |
| API token digests | HMAC-SHA-256 of the token's secret, under a key kept in a file beside the process rather than in Postgres, so a database dump alone can't test a guess (R-063) | internal/core/tokenkey, internal/core/state/tokens.go |
| Identity assertions to apps | Ed25519 (JWT EdDSA), 120-second lifetime, key ID from SHA-256 of the public key, rotation by JWKS overlap |
internal/core/assertion |
| Secrets at rest | AES-256-GCM, per-value nonce, adapter reference as additional authenticated data | internal/adapter/secrets/local |
| Backup and DR bundles | ChaCha20-Poly1305, key derived from the passphrase with argon2id, parameters written into the envelope | internal/core/backup |
| Content fingerprints and digests | SHA-256 | internal/core/deploy, internal/core/backup |
| All keys, salts, nonces, tokens, and generated passwords | crypto/rand |
throughout |
Credential verification compares derived keys with crypto/subtle.ConstantTimeCompare. When the
account does not exist, sign-in verifies against a decoy argon2id hash rather than returning early,
so a failed attempt costs the same whether or not the username is real.
math/rand does not appear anywhere in the non-test source. MD5, SHA-1, DES and RC4 are not used.
Every key gives at least 128-bit security: Ed25519 (≈128-bit), AES-256 and ChaCha20-Poly1305
(256-bit). There is no configuration that lowers them, because no
algorithm or key length is configurable.
Forward secrecy is a property of the TLS terminator in front of Pando, not of Pando itself. Pando holds no long-term key that a recorded session could be decrypted with later: assertion signing keys sign, they do not encrypt.
- Memory safety. Go and TypeScript, both memory-safe. There is no cgo and no
unsafein the source, and the binary is built withCGO_ENABLED=0. - Static analysis.
golangci-lintincludinggosec, plus CodeQL for Go and TypeScript on every push and weekly. See.github/workflows. - Dependency vulnerabilities.
govulncheckon every push and daily, Dependabot for Go modules, npm, GitHub Actions and Docker base images. Findings that are understood and accepted are listed, with the reasoning, in.github/govulncheck-allowlist.txt; anything not in that file fails the build. Nothing is accepted today. The file also records, as comments, findings that need no entry because govulncheck reports them as not called, so the reasoning is reviewable in the repository rather than only in the Security tab. - Secret scanning.
gitleaksover the full history on every push, in addition to GitHub's own push protection. - Dynamic analysis. The whole test suite runs under the Go race detector. Native Go fuzzing covers the parsers that see untrusted input — credential hashes, identity assertions, app specs, compose files, and backup envelopes.
- Structural invariants. The rules in §2 of
CLAUDE.mdare enforced by database constraints, triggers, grants and lint rules rather than by review, because review eventually misses one.