Security
Pitangus reads your repositories’ code and stores GitHub and Jira credentials. This document explains how it protects that information, what leaves your machine and which trade-offs it makes. To report a vulnerability, see SECURITY.md.
Secrets
Section titled “Secrets”| Secret | Where it lives | Who can see it |
|---|---|---|
| GitHub App private key | vault_entries table, encrypted |
Only the server process. GitHub receives a signed JWT, never the key. |
| OpenAI / Anthropic keys | vault_entries table, encrypted |
Only the server; they’re validated against the provider before being saved. The panel shows the last 4 characters. |
| Jira token | vault_entries table, encrypted |
Same as above. |
| Container registry tokens | vault_entries table, encrypted |
Only the server. They reach Trivy and Grype as environment variables (-e NAME with no value on the command), never on the command line. |
| GitHub installation tokens | Memory, 1 h | Renewed automatically; never written to disk. |
| Master key | config/master.key (0400) or PITANGUS_MASTER_KEY |
Whoever administers the server. |
| User passwords | users table in PostgreSQL, scrypt hash only |
Nobody: they can’t be recovered. |
| Session cookies | sessions table, hash only; signed with a key sealed in the vault (or PITANGUS_SESSION_KEY) |
A copy of the database doesn’t grant access. |
Encryption. AES-256-GCM, a random nonce per secret, and the secret’s name as associated data: an encrypted value can’t be moved to another entry without decryption failing, and any tampering is detected. If the master key doesn’t decrypt the vault, the server says so instead of using corrupted data.
Separation. The encrypted secrets live in the database, the master key apart from it (PITANGUS_MASTER_KEY or config/master.key), and data/ only holds caches and logs, so it carries no secrets. A database dump without the master key reveals nothing. On any platform with a secrets manager, set PITANGUS_MASTER_KEY there: it’s the one value that must never live next to the database backups. make backup and the backup service respect that: they leave the key out, or encrypt the whole backup, key included, with age to a public key whose private half stays off the server (PITANGUS_BACKUP_AGE_RECIPIENT).
Logs. Request bodies, headers, passwords, TOTP codes and cookies are never logged. On top of that, every message goes through a filter that redacts:
- known patterns:
ghp_,ghs_,github_pat_,sk-…,xox…,AKIA…,ATATT…, JWTs,Bearer …,Basic …and-----BEGIN … PRIVATE KEY-----blocks; - any value stored in the vault, matched literally, even if it follows no known pattern.
Browser. No secret is ever sent back to the browser. The .pem key is read in your browser and travels to the server once, when you connect it; the panel doesn’t keep it.
Panel access
Section titled “Panel access”- First administrator created with a one-time code that only appears in the server console: whoever opens the URL before you can’t take over the instance.
- Passwords hashed with scrypt (N=2¹⁵, r=8, p=1), 12 characters minimum. A nonexistent user costs the same as a real one, so response times don’t reveal which users exist.
- Two-factor authentication with TOTP (RFC 6238), required for administrators by default, with 8 single-use backup codes. A code that was already used doesn’t work twice.
- Server-side sessions, with an
HttpOnly,SameSite=Strictcookie that is alsoSecureover HTTPS. Changing your password, turning on TOTP or having an administrator reset your credentials signs out your other sessions. - Rate limiting per user and per address: after 5 failures, a progressive lockout from 30 s to 15 min. It also applies to the initial setup and to the return from GitHub. Behind a reverse proxy, the address comes from
X-Forwarded-Foronly when the request comes from a proxy listed inPITANGUS_FORWARDED_ALLOW_IPS(compose.prod.yamlsets it for Caddy); otherwise everybody would share the proxy’s address. - CSRF: every POST requires an allowed
Originand an action header specific to its route. - Roles:
adminconnects integrations, manages users and accepts risks;memberscans and triages.
Transport
Section titled “Transport”- By default, the port is published on
127.0.0.1only. - If
PITANGUS_PUBLIC_URLpoints outside this machine and isn’t HTTPS, the server won’t start. OnlyPITANGUS_ALLOW_INSECURE_HTTP=1allows it, at your own risk. - With HTTPS: HSTS (1 year) and
Securecookies. TLS 1.2 minimum when the server itself terminates TLS. - Every response carries a strict
Content-Security-Policy(no inline scripts or styles),Referrer-Policy: no-referrer,X-Frame-Options: DENY,X-Content-Type-Options: nosniffandPermissions-Policy. - Outbound calls use HTTPS with certificate verification and don’t follow redirects, so a credential never ends up somewhere other than intended.
What leaves your machine
Section titled “What leaves your machine”| Destination | What is sent | When |
|---|---|---|
api.github.com |
Your App’s JWT, repository and PR requests; comments and commit statuses on your PRs | When you connect, scan and review PRs. Code is downloaded from GitHub; it isn’t uploaded anywhere else. |
services.nvd.nist.gov |
Index and date ranges | Local CVE copy, in the background. |
www.cisa.gov, epss.empiricalsecurity.com |
Nothing: full public feeds are downloaded | Once a day. They’re downloaded whole so they don’t reveal which CVEs you care about. |
| Image registry and Trivy database | Nothing of yours | At build time and when Trivy updates its database. |
| Container registries (Docker Hub, GHCR, ECR…) | A request for the image you asked to scan, with your token if you saved one | When scanning an image. Registries on private IPs are blocked unless PITANGUS_ALLOW_PRIVATE_REGISTRIES=1, so the form can’t be used as a bridge into your internal network (SSRF). The check runs again when the scan starts, and the engine’s container gets the checked address for the registry’s name, so an answer that changes in between (DNS rebinding) isn’t followed. |
api.osv.dev |
Your dependencies’ names and versions | Only if you allow it, per scan. Not used by default. |
| Your Jira site | The fields you map for each issue (by default title, description, priority, labels and due date), and comments when a finding is verified fixed or reappears | Only if you connect Jira: when someone exports, or on its own for automatic routing rules. |
api.openai.com, api.anthropic.com |
The server’s key, to check that it’s valid | Only when an operator tests it (ai-check). AI isn’t used yet: it receives no code and no findings. |
| Your domains | An HTTPS HEAD and a DNS TXT lookup |
Only once dynamic testing is available (in development). Only to public addresses. |
There’s no telemetry.
Code analysis
Section titled “Code analysis”- Repository code is never executed: Pitangus analyzes a read-only snapshot.
- Engines run in ephemeral containers with a read-only filesystem (they write only to an in-memory
/tmpof 1 GB and to the folders they’re given),--cap-drop ALL,no-new-privilegesand memory, CPU and process limits. Gitleaks, Opengrep, Checkov and zizmor have no network; Trivy and Grype use it only for their vulnerability database and, when scanning an image, to read it from the registry. - Container images you scan are neither run nor built: the engines read the manifest and the layers.
- The Trivy, OSV-Scanner, Gitleaks, Grype, Checkov and zizmor images are pinned by digest. The Opengrep image is built from the official binary, checked against its SHA-256.
- Values of secrets found in your code are redacted: findings keep the location and the type, not the value.
- Findings are stored as message codes with parameters, not as frozen sentences, so the panel, reports and PR comments render them in English or Spanish for whoever reads them. Advisory text from NVD, OSV or GHSA is shown as published, never machine-translated.
Known trade-offs
Section titled “Known trade-offs”- Docker socket. Engines are launched through
/var/run/docker.sock, which is equivalent to root on the host. Only theworkerservice mounts it, and it exposes no ports; the service that handles requests (api) doesn’t have it. That’s the price of installing nothing but Docker, and the default on a laptop. On a server,make setup DOMAIN=…anddeploy/compose.yamluse the worker image with the engines inside instead (PITANGUS_ENGINE_RUNNER=local); the socket is then an opt-in (SOCKET=1). - Engines inside the worker (
PITANGUS_ENGINE_RUNNER=local). No Docker socket, but also no container per engine: each engine runs as a process of the worker, with a fresh home folder, onlyPATHfrom the worker’s environment (the database URL and the master key aren’t passed to it), no core dumps, and its whole process group killed when it times out. Engines that need no network (Gitleaks, Opengrep, Checkov, zizmor) run in an empty network namespace (unshare --user --net) where the platform allows it: on a server or a VM, yes; in a container with Docker’s default seccomp profile, no, and the worker says so in its log (local_engines_share_the_network). Elsewhere they run with their offline options wherever they have them. The boundary is the worker’s container, not the engine: an engine broken by a hostile repository runs as the worker’s user and can read what the worker can (/proc/1/environ,config/master.key). It doesn’t reach the host. The comparison with the socket is in deploy-vps.md. - Job queue. Pending scans live in PostgreSQL. The code tokens that go with a scan are sealed with the master key (AES-GCM): the database never stores them in plaintext.
- TOTP seeds are sealed with the master key too: a database dump alone can’t produce codes. Losing the master key
means resetting second factors (
make cli ARGS="user reset-totp --username <name>") as well as re-entering the vault’s secrets. - Master key on the same server if you don’t set
PITANGUS_MASTER_KEY. It protects against a stray copy of the database, not against someone with full access to the server. - A single workspace per installation: every user sees every connected repository.
- Registry token visible to root. While a private image is being scanned, the token sits in the engine container’s configuration: anyone with Docker access on the host (who is already root) can read it. Use read-only tokens.
- Registry redirects aren’t checked. Registries serve image layers from other hosts (a CDN, a bucket), so Trivy and Grype follow their redirects. A registry you ask to scan could point them at an internal address. They only request, and what comes back isn’t shown. Scan images from registries you trust, or run the engines on a network without access to your internal one.
Recommendations
Section titled “Recommendations”- Keep the panel on
127.0.0.1unless you need remote access, and then use HTTPS. - Require TOTP for everyone (
PITANGUS_REQUIRE_TOTP=all) if several people use it. - Install the App only on the repositories you want to scan (Only select repositories).
- Keep the master key apart from the backups, and encrypt the backups with age before they leave the server (
PITANGUS_BACKUP_AGE_RECIPIENT). - If an App key leaks: revoke it on GitHub (Private keys → Delete), generate a new one and reconnect it in Integrations.
Exclusions and suppressions
Section titled “Exclusions and suppressions”- Excluded paths are decided by an administrator in the panel and stored on the server with author, date and reason. A PR can’t exclude its own code or change the rules: no
.pitangus-ignore, no.gitleaks.toml, no engine configuration inside the repository is honored. - Patterns work like
.gitignore: a pattern that matches a folder excludes everything inside it, sofixturesalso coversfixtures/a/b.py. Patterns that would exclude everything are rejected. - Excluded findings are counted on every run and can be reviewed; they’re never silently deleted.
- In-code suppressions (
# nosemgrep: <rule>) do travel with the repository and show up in the PR review; add a justification next to them.