Environment variables
The portal reads its configuration from .env. The bundled .env.example enumerates every supported key. The install wizard (scripts/install.sh) populates the required keys with strong defaults; the rest you set as needed.
Operators tuning a deployment. Familiarity with .env files and Docker Compose's variable substitution.
Reading order
.envin the repo root is loaded bydocker-composeautomatically.- Backend code calls
os.getenv()at runtime, never at module import time. This isCLAUDE.mdrule #11. Restarting the container is enough to pick up a changed value — no rebuild needed. - Compose substitutes
${VAR}references indocker-compose.ymlfrom.envatdocker-compose uptime.
Every key listed below is read by apps/backend/core/config.py, docker-compose.yml, or scripts/* — the Read by column tells you which.
Required keys
These four must be present and non-empty. The wizard sets them.
| Key | Set by | Read by | Notes |
|---|---|---|---|
SECRET_KEY | wizard (openssl rand -hex 32) | config.py | JWT signing key (HS256). Minimum 32 chars in non-dev. Rotating invalidates every refresh token. |
DATABASE_URL | wizard | config.py, docker-compose.yml | postgresql+asyncpg://user:pass@postgres:5432/trustedoss. Must use the postgres host (compose service name). |
CORS_ALLOWED_ORIGINS | wizard | config.py | Comma-separated. Production must enumerate origins explicitly — * is rejected at boot when allow_credentials=true. |
DOMAIN | wizard | docker-compose.yml | Hostname used by Traefik's host-rule. Stripped of scheme and path. |
Application
| Key | Default | Read by | Description |
|---|---|---|---|
APP_ENV | dev | config.py | dev, staging, or prod. Drives a few CORS / log defaults. |
LOG_LEVEL | INFO | config.py | DEBUG, INFO, WARNING, ERROR. |
LOG_MAX_SIZE | 20m | docker-compose.yml | Size at which a container's log file rotates. |
LOG_MAX_FILE | 5 | docker-compose.yml | Rotated files kept per container. With the default size, 100 MB per service. |
DEMO_READ_ONLY | false | config.py | When truthy (1/true/yes/on), the backend runs as a read-only live demo: every non-auth mutation (POST/PUT/PATCH/DELETE) is rejected with an RFC 7807 403. Surfaces on GET /health so the SPA shows a banner. See Live demo. |
IMAGE_TAG | 0.11.0 | docker-compose.yml | Pinned tag for ghcr.io/trustedoss/trusca-backend, …/trusca-backend-worker, …/trusca-frontend. |
UVICORN_WORKERS | 4 | Dockerfile.prod (uvicorn CLI), config.py | Uvicorn worker process count for the backend container. Raising it uses more CPU cores per container instead of running more containers; multiply it into the connection-budget formula below before raising it. |
Database
DATABASE_URL (above) is the canonical setting. The composed alternative below is provided so the GCP Cloud Run module can mount DB_PASSWORD from Secret Manager without baking the DSN into Terraform state. Set either DATABASE_URL or the four DB_* keys — never both.
| Key | Default | Read by | Description |
|---|---|---|---|
DATABASE_URL | — | config.py, docker-compose.yml | See above. |
DB_USER | — | config.py | Composed-DSN: username. URL-encoded in the resulting DSN. |
DB_PASSWORD | — | config.py | Composed-DSN: password. URL-encoded so @, :, /, #, % survive parsing. |
DB_HOST | — | config.py | Composed-DSN: host. May be a Cloud SQL Auth Proxy unix socket path (/cloudsql/...). |
DB_PORT | 5432 | config.py | Composed-DSN: port. |
DB_NAME | — | config.py | Composed-DSN: database name. |
POSTGRES_USER | trustedoss | docker-compose.yml | Used by the postgres container's init. Must match DATABASE_URL. |
POSTGRES_PASSWORD | — | docker-compose.yml | Generated by the wizard. |
POSTGRES_DB | trustedoss | docker-compose.yml | Database name. |
DATABASE_URL_OWNER | falls back to DATABASE_URL | config.py | L1 role separation: the DDL-capable superuser DSN, used only for alembic upgrade head and the startup role check. |
DATABASE_URL_APP | falls back to DATABASE_URL | config.py | L1 role separation: the DML-only runtime DSN the backend and Celery worker connect with day to day. |
POSTGRES_APP_PASSWORD | (unset) | docker-compose.yml | Password for the trustedoss_app role Compose provisions on first boot when this is set. Required to actually run L1 under Compose, not just to declare the two DSNs above. |
REQUIRE_DB_ROLE_SEPARATION | false | core/db_role.py | When true, refuses to start (rather than warning) if the connected role still holds DDL privileges. See Hardening for the full L1 role-separation setup. |
If any of the four DB_* keys is set, all of them must be set (or the composed branch raises at boot). The portal uses async SQLAlchemy + asyncpg. Connection pool sizing (DB_POOL_SIZE, DB_MAX_OVERFLOW, DB_SYNC_POOL_SIZE, DB_SYNC_MAX_OVERFLOW) is documented in .env.example's "Postgres connection budget" section: multiply the FastAPI pool by uvicorn workers × backend replicas, add the Celery worker and beat pools, and stay under Postgres max_connections. The backend logs a warning at boot if your deployment's shape does not fit.
Redis & Celery
| Key | Default | Read by | Description |
|---|---|---|---|
REDIS_URL | redis://redis:6379/0 | config.py | Broker + result backend. |
CELERY_CONCURRENCY | 2 | docker-compose.yml | Worker process count. Each slot needs ~2 GB RAM at peak. |
Authentication
| Key | Default | Read by | Description |
|---|---|---|---|
SECRET_KEY | — | config.py | See Required keys. HS256 signing. |
ACCESS_TOKEN_EXPIRE_MINUTES | 30 | config.py | JWT access token lifetime. |
REFRESH_TOKEN_EXPIRE_DAYS | 7 | config.py | Refresh token lifetime. Rotation + reuse detection enabled. |
REGISTRATION_RATE_LIMIT | 5/minute | config.py | Per-IP slowapi limit for POST /auth/register, which performs a bcrypt password hash. |
REFRESH_TOKEN_RETENTION_GRACE_DAYS | 1 | tasks/auth_token_retention.py | Days past a refresh token's own expires_at before the daily sweep deletes the row. A rotated / logged-out / reuse-revoked row keeps its original expires_at, so it is caught by this same predicate within one REFRESH_TOKEN_EXPIRE_DAYS window of being revoked; there is no separate revoked-at pass. |
PASSWORD_RESET_TOKEN_RETENTION_GRACE_DAYS | 1 | tasks/auth_token_retention.py | Days past a password-reset token's own expires_at before the daily sweep deletes the row. Same reasoning as the refresh-token grace above. |
Vulnerability data
The portal correlates SBOMs against CVEs using a local Trivy DB — a compiled bundle of NVD + OSV + GHSA + EPSS + KEV. See Vulnerability data (Trivy DB) for the lifecycle.
| Key | Default | Read by | Description |
|---|---|---|---|
TRIVY_DB_REPOSITORY | ghcr.io/aquasecurity/trivy-db | config.py | OCI repository the Trivy DB is pulled from. Override for an air-gapped internal mirror — see Air-gapped operation. |
TRIVY_DB_REFRESH_HOURS | 168 (weekly) | config.py | Celery Beat schedule for the trivy_db_refresh task. Lower for fresher feeds, higher to reduce egress. |
TRIVY_CACHE_DIR | /var/lib/trivy | integrations/trivy.py | Directory the DB is unpacked into. Backed by the shared trivy-cache volume — worker (rw) and backend (ro) mount it so the admin health / disk panels can read the DB state. |
TRIVY_TIMEOUT_SECONDS | 300 | config.py | Per-scan timeout for trivy sbom. Raise to 600–900 for very large monorepos. |
KEV catalog
Independently of the Trivy DB bundle, the portal syncs the CISA KEV (Known Exploited Vulnerabilities) catalog into its vulnerability catalog once a day (Celery beat task trustedoss.kev_catalog_refresh, ~1,600 entries, delistings included). KEV-listed findings carry a badge and a remediation due date and drive the default Priority sort — see Vulnerabilities — KEV.
| Key | Default | Read by | Description |
|---|---|---|---|
KEV_FEED_URL | https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json | config.py | URL the daily refresh downloads the KEV feed from. Override to point at an internal mirror of the CISA JSON. |
KEV_REFRESH_ENABLED | true | config.py | Toggles the daily refresh. Set false on air-gapped deployments that cannot reach the feed — with the refresh off, no KEV data is loaded, so KEV badges and due dates are not shown and the Priority sort effectively degrades to severity → EPSS. |
KEV_REFRESH_TIMEOUT_SECONDS | 30 | config.py | Outbound HTTP timeout for the CISA feed download. |
Vulnerability SLA
Per-severity remediation-SLA windows, counted from a finding's project-level first detection (carried forward across re-scans and re-matches). The vulnerabilities list computes the due date and the overdue / imminent / ok state from these windows; a daily sweep raises an in-app alert when open findings cross their deadline. See Vulnerabilities — Remediation SLA and aging.
| Key | Default | Read by | Description |
|---|---|---|---|
VULN_SLA_DAYS_CRITICAL | 7 | config.py | Remediation window (days) for Critical findings. A non-numeric or non-positive value falls back to the default rather than disabling the clock. |
VULN_SLA_DAYS_HIGH | 30 | config.py | Remediation window (days) for High findings. Same fallback rule. |
VULN_SLA_DAYS_MEDIUM | 90 | config.py | Remediation window (days) for Medium findings. Same fallback rule. |
VULN_SLA_DAYS_LOW | 180 | config.py | Remediation window (days) for Low findings. Same fallback rule. Info / Unknown severities have no window and no key — they carry no SLA. |
These are read at call time, so changing one takes an edit and a restart. Every finding the new window puts past its deadline went past it in the past, and the SLA sweep only alerts on deadlines crossed within its trailing window, so none of them produce a notification. Nobody is told, and the change surfaces later as a longer ?sla=overdue list.
Each backend start logs vuln_sla.overdue_at_boot with the window values in force and the overdue count per severity. Comparing the line from before the restart with the one after shows what the change did: high from 30 to 7, overdue from 40 to 380. The count is exact at any size, because it is one aggregate rather than a scan; a scan would have to stop somewhere, and a deployment past that point would report the same number on both sides of the change.
The log is the whole of it. There is no alert, so somebody has to look, and the reason to keep it is the person who asks months later when the backlog grew.
| VULN_SLA_ALERTS_ENABLED | true | config.py | Toggles the daily SLA-breach sweep (Celery beat trustedoss.vuln_sla_sweep, 02:45 UTC). The sweep is a pure internal computation with no egress; only the exact tokens false / 0 / no disable it. |
Build gate
The CI build gate fails a build on Critical CVEs and forbidden licenses out of the box; those conditions are not env-driven. The single env knob below adds an optional EPSS dimension.
| Key | Default | Read by | Description |
|---|---|---|---|
GATE_MALICIOUS_ENABLED | true | policy_gate.py | Whether the build gate blocks on known-malicious packages. On by default, unlike the other GATE_* knobs: those tune how strictly an existing signal is read, this one decides whether an active attack reaches production. Blocks regardless of severity — a malicious package has no honest version to upgrade to. When off the gate's malicious_component_count is 0 because nothing was checked, and malicious_gate_enforced says so. Only false / 0 / no disable. |
GATE_EPSS_THRESHOLD | (unset) | config.py | Optional EPSS gate. A value from 0 to 1. When set, the build gate also fails if any open finding has epss_score >= GATE_EPSS_THRESHOLD, and the gate result carries epss_gate_count + epss_threshold. Unset (the default) disables the EPSS gate — only the existing Critical-CVE / forbidden-license conditions apply. Findings without an EPSS value never trip the gate. EPSS data is sourced from the Trivy DB, so only CVEs Trivy supplies a value for are eligible. |
GATE_EPSS_ON_MISSING_DATA | allow | config.py | What the gate does when a configured GATE_EPSS_THRESHOLD could not be evaluated because no open finding on the scan carries an EPSS score. allow (the default) lets the build through, which is the behaviour every deployment had before this option existed, so upgrading changes no result. block fails the build, so a configured threshold cannot be ignored in silence. Applies to the "nothing scored at all" case only: partial coverage is normal, because EPSS does not score every CVE, and never blocks. The gate result reports which case it was in epss_outcome. |
See build gate for the gate model and Gate the build on EPSS for the CI walkthrough.
Groups
| Key | Default | Read by | Description |
|---|---|---|---|
GROUP_CASCADE_ENABLED | true | config.py | Whether a membership or API key scoped to a group also reaches that group's subgroups. On by default: a group only reaches its descendants once an administrator has moved it into the hierarchy on purpose, so turning this on changes nothing for a deployment that has not yet nested any groups. false restores the pre-hierarchy behavior of a flat, single-group scope. |
GROUP_SEARCH_RATE_LIMIT | 20/minute | config.py | slowapi limit for GET /v1/groups in search mode (?q=), keyed per authenticated user. This is the endpoint behind the project-creation group picker's live-typing search, so it is sized for keystroke-rate calls rather than the occasional read the drill-down mode gets. |
See Nested groups for the cascade's user-facing behavior.
Scan pipeline
| Key | Default | Read by | Description |
|---|---|---|---|
TRUSTEDOSS_SCAN_BACKEND | real | config.py | real (subprocess cdxgen / scancode / Trivy) or mock (fixture JSON). mock is the dev / CI default for the test harness; production must leave this as real. |
SCAN_LOAD_TEST_DELAY_ENABLED | false | config.py | Load-test-only knob: when a scan pipeline task checks it and finds it enabled, it sleeps SCAN_LOAD_TEST_DELAY_SECONDS and marks the scan succeeded instead of running cdxgen/Trivy. The mock backend finishes too fast to build a queue, so this exists to hold a worker slot busy long enough to measure trigger-to-started_at and started_at-to-completed_at gaps under N concurrent triggers. Off by default, and refused outside APP_ENV=dev even when set. config.scan_load_test_delay_seconds() returns 0.0 (disabled) on any deployment where APP_ENV is not dev, logging a WARNING if the flag was set anyway. Never enable this against a real deployment; every scan it touches reports fabricated success. |
SCAN_LOAD_TEST_DELAY_SECONDS | 5 | config.py | Seconds a load-test-mode scan task sleeps before completing (see SCAN_LOAD_TEST_DELAY_ENABLED). Clamped to [0.1, 3600]. Only takes effect when the enable flag above is also set and APP_ENV=dev. |
SCANCODE_TIMEOUT_SECONDS | 600 | config.py | Hard wall-clock limit for the scancode first-party license stage. On timeout the scan continues with declared licenses only (best-effort). |
SCANCODE_MAX_FILES | 20000 | config.py | Ceiling on eligible first-party files (after the exclude filter). Over this, scancode is skipped and the scan keeps declared licenses only. |
SCANCODE_MAX_DETECTIONS | 5000 | config.py | Cap on the number of detected-license findings persisted per scan. |
SCANCODE_MAX_RESULT_BYTES | 268435456 (256 MB) | config.py | Ceiling on the scancode JSON artefact before parsing — guards against an OOM from a hostile tree. |
SCANOSS_ENABLED | false | config.py | Master opt-in for the SCANOSS vendored-OSS stage. Off by default. When true, the stage fingerprints the source tree and sends those fingerprints (never source) to SCANOSS_API_URL to identify copied-in OSS — enable only with operator consent to that external egress. When false the stage is skipped entirely (no scanner, no egress). See Components & licenses → Vendored-OSS identification. |
SCANOSS_API_URL | https://api.osskb.org | config.py | SCANOSS knowledge-base endpoint the fingerprints are matched against (used only when SCANOSS_ENABLED=true). Point this at a self-hosted SCANOSS instance to keep fingerprints inside your network. |
SCANOSS_API_KEY | (empty) | config.py | Optional API key for SCANOSS_API_URL (a paid / self-hosted endpoint). Empty uses the free api.osskb.org tier. |
SCANOSS_TIMEOUT_SECONDS | 300 | config.py | Hard wall-clock limit for the SCANOSS stage. On timeout the scan continues without vendored-OSS results (best-effort). |
TRUSTEDOSS_VERSION | unknown | config.py | The version this deployment states as its own — in SLSA provenance, on the About screen, and as the SBOM tool version in every document TRUSCA produces. Release images inject the tag at build time (ARG TRUSTEDOSS_VERSION). The default is deliberately not a plausible version number: a placeholder would make all three assert a release that does not exist, and unknown is what the 2026 SBOM minimum elements ask for when no identifier is available. |
TRUSTEDOSS_COMMIT | unknown | config.py | The commit this image was built from, injected at build time the same way as TRUSTEDOSS_VERSION. Surfaced on the About screen and the trusca_build_info metric so an operator can tell which build is actually running. |
TRUSTEDOSS_BUILT_AT | unknown | config.py | UTC build timestamp (ISO 8601), same injection mechanism as TRUSTEDOSS_COMMIT. Also published as trusca_build_time_seconds so a collector can compute image age without parsing a metric label. |
SBOM_AUTHOR | (unset) | config.py | The entity that creates the SBOM data, recorded as the SBOM author on every export. The 2026 SBOM minimum elements ask an SBOM to name it, and nothing in a scan can discover it — it is the organisation operating the portal. Left unset the field is omitted rather than filled with a placeholder, which would satisfy the element while telling a recipient nothing. |
SCAN_SCOPE_FILTER_ENABLED | true | config.py | Master switch for the runtime-scope post-filter: source scans drop non-deployable dependencies (Maven test/provided, npm devDependencies) from the SBOM before persist, signing and Trivy matching. Pure local transformation, no egress. Only the exact tokens false / 0 / no disable it. See Components & licenses → Runtime-scope filtering. |
SCAN_SCOPE_FILTER_MAVEN_ENABLED | true | config.py | Maven half of the scope filter (drops cdxgen scope optional/excluded nodes). Disable if a project relies on Maven <optional>true</optional> runtime dependencies — cdxgen tags those optional exactly like test scope, so they are dropped too. |
SCAN_SCOPE_FILTER_NODE_ENABLED | true | config.py | npm half of the scope filter (drops packages the committed/generated package-lock.json classifies as dev). A package absent from the lockfile is always kept. |
LICENSE_FETCH_ENABLED | true | config.py | Post-cdxgen license enrichment. When cdxgen emits a component with no SPDX license (common for a bare requirements.txt / go.mod), the pipeline asks the component's public registry (PyPI / Maven Central / crates.io / pkg.go.dev / RubyGems / NuGet) for the declared license by purl and caches it — this pulls the "unknown" license ratio down. Only a package name+version leaves the network (the registry the package manager already contacts), so it defaults on, unlike the SCANOSS fingerprint egress. Set false / 0 / no on an air-gapped deployment so an unlicensed component stays unknown instead of paying a per-component network timeout. The fetcher also re-checks a component whose SBOM recorded exactly one licence that would block the build: package metadata often lists several licences the recipient may choose between, and an SBOM that carries only the first of them fails a gate the package does not deserve to fail. |
EXTERNAL_PACKAGE_LOOKUP_ENABLED | true | config.py | Whether the deps.dev package/advisory lookup (pre-adoption catalog search) makes outbound calls, gating both GET /v1/external-packages and GET /v1/external-advisories/{id}. Same shape as LICENSE_FETCH_ENABLED: only a package name/ecosystem or an advisory id leaves the network, to a fixed public host (api.deps.dev), so it defaults on. Set false / 0 / no on an air-gapped deployment to hide the lookup entry points and answer 404 rather than a per-call timeout. |
MALICIOUS_ENABLED | true | config.py | Known-malicious package flagging: components are matched against a snapshot of OSV MAL- advisories vendored with the release and stamped flagged / clear on the shared catalog. Fully offline, zero egress. Not a vulnerability axis — no finding is created and the count never enters severity totals. Turning this off leaves the columns unset, which surfaces render as not assessed rather than clean. Only false / 0 / no disable. See Components & licenses → Known-malicious packages. |
MALICIOUS_REFRESH_ENABLED | false | config.py | Whether the weekly beat may rebuild the malicious snapshot from the OSV archives (~274 MB). Off by default like every other new egress target. The re-stamp half of that beat always runs — it is local — so leaving this alone still lets a release upgrade reach existing rows. Air-gapped installs never turn it on. |
MALICIOUS_WAIVE_MAX_DAYS | 30 | license_policy_service.py | Longest lifetime a malicious-package waiver may have. Shorter than LICENSE_WAIVE_MAX_DAYS on purpose: a licence waiver can be a settled decision, a malicious waiver only buys time to challenge the advisory upstream. Lowering it does not shorten waivers already written. |
MALICIOUS_SNAPSHOT_STALE_DAYS | 60 | config.py | Age at which the admin panel calls the malicious snapshot stale. Shorter than the EOL panel's 180 because advisories are published daily. |
EOL_ENABLED | true | config.py | End-of-life flagging: components matching the endoflife.date product whitelist are stamped eol / supported / unknown on the shared catalog. Fully offline — verdicts come from a snapshot vendored with the release, zero egress. Only false / 0 / no disable. See Components & licenses → End-of-life flagging. |
EOL_SNAPSHOT_PATH | (empty — vendored file) | config.py | Operator override for the endoflife.date snapshot. Air-gapped installs can build a fresher snapshot on a connected host (python3 scripts/refresh_eol_snapshot.py), mount it, and point this at the file. |
EOL_REFRESH_ENABLED | false | config.py | Opt-in live fetch: the weekly beat downloads fresh lifecycle data from EOL_FEED_URL_TEMPLATE. Off by default — this is new egress to a third-party host; the beat's local re-stamp pass runs either way. Only the exact tokens true / 1 / yes enable (fail-closed, the SCANOSS posture). |
EOL_FEED_URL_TEMPLATE | https://endoflife.date/api/{product}.json | config.py | Per-product API template for the live fetch ({product} is substituted). Point at an internal mirror to keep the egress inside your network. |
EOL_REFRESH_TIMEOUT_SECONDS | 15 | config.py | HTTP timeout per product request during the live fetch. Bounded [1, 120]; the whole sweep is additionally capped at 60 s wall-clock. |
WORKSPACE_HOST_PATH | /tmp/trustedoss | config.py, docker-compose.yml | Host directory mounted into the worker as /workspace. Holds repo clones + scan artefacts (cdxgen SBOM, scancode output). The compose stack overrides this to /workspace inside the container. |
ORT_RULES_PATH | /opt/trustedoss/ort/rules.kts | docker-compose.yml | Legacy path inside the worker, vestigial after the ORT stage was removed. Nothing reads it and the file it names no longer exists — license-tier classification comes from _LICENSE_CATEGORY_DEFAULTS in apps/backend/tasks/scan_source.py. |
JSONB_ROW_SIZE_LIMIT_BYTES | 262144 (256 KB) | config.py | Per-row JSON byte ceiling before the writer truncates and emits a warning. Guards the I-1 unbounded-payload class. |
Scan retention
These keys tune the automatic retention sweep that reclaims superseded and stale scan snapshots. The sweep runs as a Celery beat task every 6 hours. See Scan retention for the full model.
| Key | Default | Read by | Description |
|---|---|---|---|
SCAN_RETENTION_SUPERSEDED_GRACE_DAYS | 7 | config.py | Days a superseded snapshot is kept before the sweep reclaims it. A snapshot is superseded when a newer successful scan lands on the same (project, normalized ref) target. Set higher to keep more rollback history per target. |
SCAN_RETENTION_KEEP_LAST | 30 | config.py | Minimum number of ref-less and failed scans kept per project, regardless of age. The sweep never trims below this floor — it protects ad-hoc and diagnostic scans that carry no ref target. |
SCAN_RETENTION_MAX_AGE_DAYS | 180 | config.py | Hard age ceiling. Any non-release scan older than this is reclaimed by the sweep even if it is still the live snapshot for its target. Scans labelled metadata.release are exempt and kept forever. |
Webhook receivers
Both receivers are public: the signature covers the body, so the body is read and the repository resolved before any credential is checked. These two keys bound what an unauthenticated caller can make that cost. See Webhooks.
| Key | Default | Read by | Description |
|---|---|---|---|
WEBHOOK_MAX_BODY_BYTES | 2097152 (2 MiB) | config.py | Bodies over this are refused with 413 before being buffered. Clamped to 65536–26214400; the ceiling is what GitHub itself refuses to deliver above. |
WEBHOOK_RATE_LIMIT | 120/minute | config.py | slowapi limit string, keyed on source IP (the only identity available before the signature is checked). A 429 costs a delivery (no Git host retries a 4xx on its own), so raise this rather than lose events. |
WebSocket gateway
| Key | Default | Read by | Description |
|---|---|---|---|
WEBSOCKET_MAX_CONNECTIONS_PER_USER | 8 | config.py | Per-user concurrent connection ceiling, enforced against a Redis-backed registry shared by every backend process (exact regardless of worker or pod count). The scan detail page opens two sockets per open tab, so 8 covers four tabs at once. The connection that pushes a user over the cap is admitted; the user's oldest connection is evicted with close code 1001 (reason="newer_connection"). |
WEBSOCKET_MAX_CONNECTIONS_GLOBAL | 500 | config.py | System-wide concurrent connection ceiling across every user, same Redis-backed registry as above. A connection that would push the total over this cap is refused outright (close code 4429, reason="capacity_at_limit") rather than evicting anyone else's connection. |
WEBSOCKET_AUTH_TIMEOUT_SECONDS | 1.0 | config.py | How long the gateway waits for the first {"type":"auth"} frame. Connections that miss the window are closed with 1008 / reason="auth_timeout". |
Notifications
| Key | Default | Read by | Description |
|---|---|---|---|
SMTP_HOST | (empty) | config.py | SMTP server. Without it, email notifications raise NotificationDisabled and the channel is skipped. |
SMTP_PORT | 587 | config.py | SMTP port. STARTTLS expected on 587. |
SMTP_USER | (empty) | config.py | SMTP username. |
SMTP_PASSWORD | (empty) | config.py | SMTP password. |
SMTP_USE_STARTTLS | true | config.py | Set false only for SMTP servers that demand implicit TLS on 465 or are testing on 25. |
SMTP_FROM | no-reply@trustedoss.local | config.py | From: header for outgoing notifications. Override per environment. |
SMTP_TIMEOUT_SECONDS | 10 | config.py | Per-call SMTP socket timeout. |
SLACK_WEBHOOK_URL | (empty) | config.py | Org-wide Slack webhook for super_admin notifications. Per-team webhooks are configured in the UI. |
TEAMS_WEBHOOK_URL | (empty) | config.py | Org-wide MS Teams webhook. |
NOTIFICATION_HTTP_TIMEOUT_SECONDS | 10 | config.py | Outbound HTTP timeout for Slack / Teams webhooks. |
Password reset
| Key | Default | Read by | Description |
|---|---|---|---|
PASSWORD_RESET_BASE_URL | http://localhost:5173 | config.py | Frontend base URL embedded in reset emails. The link template is {base}/reset-password?token={token}. |
PASSWORD_RESET_RATE_LIMIT | 5/minute | config.py | Per-IP slowapi limit for POST /auth/forgot-password. |
REFRESH_RATE_LIMIT | 30/minute | config.py | Per-IP slowapi limit for POST /auth/refresh. Higher than the login limit because every open tab refreshes on a schedule; low enough that a stolen refresh cookie cannot be polled to keep a freshly minted access token in hand. |
LOGIN_THROTTLE_ENABLED | true | config.py | Whether failed sign-ins are counted per address as well as per IP. Off leaves only the per-IP limit, which does not see guessing spread across many addresses. |
LOGIN_THROTTLE_FAILURES | 10 | config.py | Consecutive failures for one address before it is refused. Deliberately above the per-IP budget of 5/minute: a threshold of 5 would fire first for anyone signing in from one machine, making that limiter's documented sixth-attempt 429 unreachable and meeting a mistyped password with the harsher control. This one is for failures accumulating against one address from many sources. |
LOGIN_THROTTLE_WINDOWS | 60,300,900,1800 | config.py | How long each successive refusal lasts, in seconds. The last value is the ceiling and repeats. A window always expires on its own, and opening the next one costs a fresh threshold of failures rather than a single attempt, so holding somebody out means sustaining the guessing. It cannot be made impossible: any control that refuses after N failures can be held open by supplying N failures. The two ways back that do not depend on the attacker stopping are completing a password reset, which needs the inbox, and POST /v1/admin/users/{id}/unlock-sign-in. Values below 1 are rejected with a warning. |
PASSWORD_RESET_CONFIRM_RATE_LIMIT | 5/minute | config.py | Per-IP slowapi limit for POST /auth/reset-password. The token is the credential there, so the endpoint is a guessing surface like login and gets the same default. |
PASSWORD_RESET_EMAIL_COOLDOWN_SECONDS | 300 | config.py | Minimum seconds between two reset emails to the same address. Returned as Retry-After on cooldown. |
OAuth (demo SaaS only)
These apply to the demo SaaS deployment. Self-hosted installs leave them empty (the /auth/oauth/{provider}/authorize endpoint then returns 503 with oauth_provider_disabled = true).
| Key | Default | Read by | Description |
|---|---|---|---|
GITHUB_CLIENT_ID | (empty) | config.py | GitHub OAuth App client ID. |
GITHUB_CLIENT_SECRET | (empty) | config.py | GitHub OAuth App client secret. |
GOOGLE_CLIENT_ID | (empty) | config.py | Google OAuth client ID. |
GOOGLE_CLIENT_SECRET | (empty) | config.py | Google OAuth client secret. |
OAUTH_STATE_TTL_SECONDS | 300 | config.py | Lifetime of the signed state JWT (CSRF guard). RFC 6749 §10.12. |
OAUTH_HTTP_TIMEOUT_SECONDS | 10 | config.py | Outbound HTTP timeout to OAuth provider APIs. |
OAUTH_LOGIN_REDIRECT_DEFAULT | http://localhost:5173/ | config.py | Where the SPA lands after a successful OAuth callback. |
OAUTH_LOGIN_REDIRECT_FAILURE | http://localhost:5173/login | config.py | Where the SPA lands when the callback fails. Receives ?error=oauth_failed. |
Single sign-on (generic OpenID Connect)
The deployment's own identity provider. One provider, not a list: an organisation has one, and every endpoint is read from its discovery document, so naming the issuer is most of the wiring. Leave OIDC_ISSUER empty and no SSO button appears.
| Key | Default | Read by | Description |
|---|---|---|---|
OIDC_ISSUER | (empty) | config.py | Issuer URL, for example https://login.example.com. Must be https: this one request decides every other endpoint, so it is the one that must not be tamperable. Endpoints in the document must sit on the same host as the issuer, which is checked at sign-in. |
OIDC_CLIENT_ID | (empty) | config.py | Client id registered with the provider. |
OIDC_CLIENT_SECRET | (empty) | config.py | Client secret. The exchange is a confidential-client authorization code flow. |
OIDC_SCOPES | openid email profile | config.py | Scopes requested at sign-in. openid is added back if omitted. |
OIDC_GROUPS_CLAIM | groups | config.py | Userinfo claim listing group membership. Unlike the address there is no standard claim to insist on, since nothing vouches for a group list either way. |
OIDC_GROUP_ROLE_MAP | (empty) | config.py | group:grade pairs, comma separated, deciding the grade a person gets on the team created at first sign-in. Empty means everyone keeps the historical grade. Once set, someone matching no group gets the lowest grade, because a deployment that has mapped its groups has said what matching none of them means. super_admin is refused here even if written: whoever can create a group in the provider would otherwise be able to mint a portal administrator. |
AUTH_AUTO_REGISTER | false | config.py | Whether an unknown person who authenticates through the deployment's own identity provider becomes a user. Off means they are refused and an administrator adds them, one at a time or in bulk. On a deployment pointed at a company directory everybody in the company can authenticate and only some of them are meant to have an account, which is why this is off rather than on. The hosted providers (GitHub, Google) are unaffected and keep creating an account on first sign-in. |
DEFAULT_MEMBER_ROLE | (empty) | config.py | The grade a deployment has chosen for people nobody graded: a bulk-registration row naming no role, or a first sign-in through your own identity provider on a deployment that has not set OIDC_GROUP_ROLE_MAP. Once a group map exists it decides the grade on its own, and somebody matching none of its groups gets viewer regardless of this setting. Empty means no choice was made and each path keeps what it granted before this setting existed, which is developer for an account an administrator adds and the personal-team team_admin for a first sign-in. A value this does not recognise resolves to viewer with a warning, rather than silently landing on something more permissive. super_admin is refused even if written. |
TICKET_WEBHOOK_URL | (empty) | config.py | Where to post an event worth raising a ticket for. Empty means off, and off means nothing is called at all. The portal posts a structured JSON event and your own adapter turns it into a ticket, because the mapping from event to ticket is where organisations differ most. The post is made by a background task, never by the flow that produced the event. |
TICKET_WEBHOOK_TOKEN | (empty) | config.py | Bearer token sent with the post. Empty means none, which is right when the URL already carries a secret in its path. |
TICKET_WEBHOOK_EVENTS | (empty) | config.py | Which event kinds are worth a ticket, comma separated. Empty means all of them, the same reading an empty condition has on a notification routing rule. |
AUDIT_EXPORT_URL | (empty) | config.py | Where to hand the audit trail as it accumulates. Empty means off; the audit API is unchanged either way. A background task posts batches every five minutes and moves its position only after the collector accepts one. A fresh destination starts at the beginning of the trail rather than at the moment it was configured. |
AUDIT_EXPORT_TOKEN | (empty) | config.py | Bearer token sent with each batch. |
AUDIT_EXPORT_BATCH_SIZE | 500 | config.py | Rows per post, clamped to 1..5000. A deployment that has fallen behind catches up over several runs rather than in one request the collector may refuse. |
AUDIT_EXPORT_LAG_SECONDS | 30 | config.py | How far behind the present the export reads, clamped to 0..3600. Not a throttle: a row is stamped when its transaction commits, so ordering by the stamp alone can place a row behind a position already passed, and it would never be sent. Raise it if your deployment holds long transactions. |
AUDIT_LOG_RETENTION_DAYS | 90 | config.py | Age past which an already-exported audit row is purge-ready. Does not delete anything: audit_logs is append-only at the database layer, and the sanctioned purge is a manual, two-operator SQL session. The daily readiness report only counts + logs rows that are both this old and past the export cursor above. See Operational data retention and Audit log → Retention. |
METRICS_ENABLED | false | config.py | Whether this deployment publishes an operational metrics endpoint at /metrics. Off answers 404 rather than 403, so a deployment without a scrape target looks like one without the feature. What it publishes is a fixed list of aggregate counts held to tests/contracts/metrics-series.json; no project, package or person's name appears in the output. |
METRICS_TOKEN | (empty) | config.py | A bearer token a scraper must present. Empty means open to anyone who can reach the endpoint, which is the usual arrangement when /metrics is off the public ingress. Set it when the endpoint is reachable from somewhere you do not control. A wrong token answers 404, the same as switched off, and the comparison is constant-time. |
QUEUE_BACKLOG_METRICS_ENABLED | false | config.py | Whether /metrics also publishes the broker-backlog series (trusca_broker_queue_backlog, trusca_scan_queue_wait_seconds). Off by default and independent of METRICS_ENABLED: the other series in that document only read Postgres, and this one opens a second connection to the Celery broker, so turning the endpoint on does not by itself turn this series on too. |
QUEUE_BACKLOG_ALERT_ENABLED | false | config.py | Whether the S6 beat sweep that turns a sustained Celery-queue backlog into a Slack/Teams alert runs. A Compose deployment has no autoscaler layer, so this is the signal the product gives instead - see Docker Compose - Scan capacity for the formula it pairs with. Hard dependency on QUEUE_BACKLOG_METRICS_ENABLED: turning this on while that is off does not error, it degrades to a per-tick skip logged at WARNING. |
QUEUE_BACKLOG_ALERT_SCAN_QUEUE_THRESHOLD | 10 | config.py | Messages waiting on trustedoss.scan before the sweep counts it as backlogged. Scan slots stay busy for tens of minutes (scan_hard_time_limit_seconds()), so a handful of queued scans is ordinary. |
QUEUE_BACKLOG_ALERT_DEFAULT_QUEUE_THRESHOLD | 100 | config.py | Same idea for trustedoss.default, an order of magnitude higher because that queue carries short, frequent work (notifications, backups, audit export, catalog-refresh beats - see S3's queue split) that a healthy deployment clears in seconds. |
QUEUE_BACKLOG_ALERT_SUSTAIN_SECONDS | 600 | config.py | How long a queue must stay over its threshold, in seconds, before it alerts. A momentary spike is not an incident; still breached this long after it first crossed is. |
QUEUE_BACKLOG_ALERT_COOLDOWN_SECONDS | 3600 | config.py | Minimum gap between two alerts for the same queue while it stays breached, so a multi-hour incident pages on this interval rather than on every 5-minute beat tick. A queue that outlives the cooldown alerts again. |
SCAN_QUEUE_SLOT_COUNT | 2 | config.py | S7: this deployment's scan-queue Celery slot count (WORKER_REPLICAS x CELERY_CONCURRENCY on Compose, worker.scan.replicaCount x worker.scan.concurrency on Helm), used only to compute the estimated_wait_seconds field on a 429 from the team concurrency cap. The backend cannot observe the live worker count itself, so set this to match your own deployment shape; a wrong value only makes the estimate wrong, never the 429 decision itself. |
SCAN_AVERAGE_DURATION_SECONDS | 1200 | config.py | S7: typical scan-slot occupancy (20 minutes), the M in the installation guide's capacity formula. Used with SCAN_QUEUE_SLOT_COUNT for the estimated_wait_seconds estimate above. Deliberately not the 3900s hard time limit, which is a worst case, not a typical duration. |
WEBHOOK_CAPACITY_RETRY_ENABLED | true | config.py | S7: whether a webhook-triggered scan turned away by the team concurrency cap or the disk guard is retried automatically on a bounded exponential backoff, instead of staying dropped until an operator resends the delivery from the Git host. The one toggle in the concurrency-scaling plan that defaults ON - off is the pre-S7 defect, not a preserved behaviour. Manual redelivery still works either way. |
PERMISSION_CACHE_TTL_SECONDS | 0 (off) | config.py | How long a resolved principal may be reused before it is read again, in seconds. Off by default, so every authenticated request reads the user and their memberships. Whatever you set is the longest a revocation can go unfelt: a demoted person keeps their old grade, and a deactivated one their session, for up to this long. A portal write drops the entry in the worker that handled it, but the image runs four workers, so that is an optimisation rather than a second guarantee. Clamped to 300 seconds; anything that is not a positive whole number of seconds means off. |
AUTH_SELF_REGISTRATION | true | config.py | Whether anybody may create their own account at the sign-up form. On by default, because that form is how the hosted signup works. Turning AUTH_AUTO_REGISTER off alone closes nothing: somebody signs up under their work address, then signs in through the company provider, and the callback links the identity to the account they just made. An enterprise deployment turns both off. Closed answers 404, the same answer as any route that is not there. |
The address comes from the standard email claim and the provider must report it verified. There is no setting to read it from another claim: email_verified vouches for email alone, so an address taken from elsewhere carries a flag that describes something different, and on several providers that other claim is editable by its holder. Providers let an administrator map their claim onto email, which is where that belongs.
Issuer, client id and secret must all be set, and the issuer must be https, before the provider reports itself configured. A deployment missing any of them shows no button rather than one that fails on click.
The portal does not validate ID token signatures, by design. The authorization code is exchanged directly with the issuer's token endpoint over TLS and the subject is read from userinfo over the same channel, which is what OpenID Connect Core §3.1.3.7 permits for a token obtained straight from the token endpoint. What the portal does check is that the discovery document belongs to the configured issuer and that every endpoint it names is on the issuer's own host.
Operational data retention
Three tables age out on their own occurrence-time clock; there is no export cursor or usage flag to wait on the way audit_logs has. A daily beat reclaims each past the age below. See Data retention for the full model, including why audit_logs itself is a read-only report rather than a delete.
| Key | Default | Read by | Description |
|---|---|---|---|
NOTIFICATION_RETENTION_DAYS | 180 | tasks/operational_retention.py | Age past which an in-app notification (read or unread) is reclaimed. |
WEBHOOK_DELIVERY_RETENTION_DAYS | 90 | tasks/operational_retention.py | Age past which an inbound GitHub/GitLab webhook-delivery record is reclaimed. Matches AUDIT_LOG_RETENTION_DAYS by default. |
REPORT_DOWNLOAD_RETENTION_DAYS | 365 | tasks/operational_retention.py | Age past which a report-download history row (SBOM / NOTICE / vulnerability-report emit record) is reclaimed. |
TASK_RUN_RETENTION_DAYS | 90 | services/task_run_recorder.py | Age past which a background task-run history row is reclaimed. One row per task execution, so this table grows with scheduler traffic rather than user activity. |
Backups
| Key | Default | Read by | Description |
|---|---|---|---|
BACKUP_RETENTION_DAYS | 7 | scripts/backup.sh | scripts/backup.sh --no-prune overrides on a per-run basis. |
BACKUP_DIR | <repo>/backups | scripts/backup.sh | Where the backup script writes. |
Disk guards
| Key | Default | Read by | Description |
|---|---|---|---|
DISK_HARD_LIMIT_PCT | 95.0 | apps/backend/core/config.py | Red gauge + new scans blocked + admin notification. Accepted range 50–100; a value outside it is clamped and a value that is not a number falls back to 95.0, both logged at WARNING. |
DISK_GUARD_EXTRA_PATHS | / | apps/backend/core/config.py | Filesystems the disk guard checks besides the workspace one, comma-separated. The default is the container root, which is where the toolchain caches land. Needed because WORKSPACE_HOST_PATH is often a network mount, and then reading only it reports the network volume's free space while the container's own filesystem fills. Set to an empty string to check only the workspace. A path that does not resolve is skipped with a warning, not treated as full. |
Traefik / TLS
| Key | Default | Read by | Description |
|---|---|---|---|
DOMAIN | — | docker-compose.yml | See Required keys. |
TLS_EMAIL | — | docker-compose.yml | Email used by Let's Encrypt's HTTP-01 challenge. Required for cert issuance. |
TRAEFIK_LOG_LEVEL | INFO | docker-compose.yml | DEBUG is useful when chasing routing issues. |
Optional integrations
| Key | Default | Read by | Description |
|---|---|---|---|
TICKET_STATUS_REFRESH_RATE_LIMIT | 10/minute | config.py | slowapi limit for POST /vulnerability_findings/{id}/ticket-status/refresh, keyed per authenticated user. Jira credentials themselves are not an env var: a super-admin stores one login per Jira Cloud host per organization via PUT /v1/admin/organizations/{id}/ticket-credentials (#385). See Ticket status. |
CLIENT_ERROR_REPORT_RATE_LIMIT | 30/minute | config.py | slowapi limit for POST /v1/client-errors, the frontend ErrorBoundary's crash-report intake (#423), keyed per IP since the endpoint is unauthenticated (a render crash can happen before there is a token to attach). Looser than the auth endpoints above: a broken render path can legitimately re-throw on every re-render for one visitor, and the cost per call is a single log line, not a bcrypt hash. |
HTTP_PROXY / HTTPS_PROXY / NO_PROXY | (empty) | subprocess env | Honored by git clone, cdxgen, and the trivy --download-db-only boot / refresh path. |
SSL_CERT_FILE / SSL_CERT_DIR | (empty) | subprocess env, portal HTTPS | Private certificate authority for Trivy, cosign, govulncheck and the portal's own outbound calls. Replaces the trust set for the portal, so the file must also carry the public roots. See Private certificate authorities. |
NODE_EXTRA_CA_CERTS | (empty) | subprocess env | Private certificate authority for cdxgen. Additive. |
REQUESTS_CA_BUNDLE | (empty) | subprocess env | Private certificate authority for scancode and scanoss. The portal's own HTTPS calls ignore it. |
GIT_SSL_CAINFO / GIT_SSL_CAPATH | (empty) | subprocess env | Private certificate authority for git clone. git reads neither SSL_CERT_FILE nor CURL_CA_BUNDLE, so cloning from an internal host needs one of these. |
REGISTRY_CONFIG_HOST_PATH | ./secrets/registry | docker-compose only | Host directory mounted read-only at the fixed container path /etc/trusca/registry on the scan-pipeline worker. Holds whichever of settings.xml / .npmrc / pip.conf / .netrc your private registries need. See Private registries for dependency resolution. |
MVN_ARGS | (empty) | cdxgen's own Maven invocation | Extra arguments cdxgen appends to every mvn command it runs. Set to --settings /etc/trusca/registry/settings.xml for a private Maven registry. There is no MAVEN_SETTINGS variable: Maven itself only reads ~/.m2/settings.xml or a -s/--settings flag. |
NPM_CONFIG_USERCONFIG | (empty) | subprocess env | npm's own environment variable for an alternate .npmrc path, not a cdxgen feature. |
PIP_CONFIG_FILE | (empty) | subprocess env | pip's own environment variable for an alternate pip.conf path, not a cdxgen feature. |
Bootstrap / scripts
These keys are read only by the bootstrap and demo-seed scripts. They are not consumed by the running backend, but you set them at install / demo time.
| Key | Default | Read by | Description |
|---|---|---|---|
ADMIN_EMAIL | — | apps/backend/scripts/create_super_admin.py | Email of the first super-admin to provision when the script is invoked. Lower-cased and stripped on read. |
ADMIN_PASSWORD | — | apps/backend/scripts/create_super_admin.py | Password for the bootstrap super-admin. Must be ≥ 12 characters; the script aborts otherwise. |
DEMO_SUPER_ADMIN_PASSWORD | a fixed value published with the public demo | apps/backend/scripts/seed_demo.py | Password given to every seeded demo account. Unset means the published default, not a generated one, so pin your own on any instance others can reach. Seeding refuses to run at all unless APP_ENV is dev or demo. Must be ≥ 12 characters when set. |
SUBJECT_USER_ID | - | apps/backend/scripts/anonymise_user.py | The user whose personal data the anonymisation command erases. A UUID; the command aborts on anything else. See User anonymisation. |
CONFIRM | - | apps/backend/scripts/anonymise_user.py | Must be yes for the anonymisation to run. Two super admins approving is a decision about the subject; this is the operator confirming they mean to run it now, against this id, on this deployment. The erasure cannot be undone. |
MODE | count | apps/backend/scripts/reencrypt_secrets.py | count reports how many stored secrets are still encrypted under an older key and changes nothing; rewrite moves them onto the newest key. count must report zero before an old key is removed from GITHUB_APP_ENCRYPTION_KEY, because rows still holding ciphertext from a removed key cannot be recovered. See Rotating the encryption key. |
Validation
The backend validates the configuration on startup (apps/backend/main.py lifespan):
- Refuses to start if
SECRET_KEYis shorter than 32 chars (any non-devAPP_ENV). - Refuses to start if
CORS_ALLOWED_ORIGINScontains*while credentials are allowed. - Refuses to start in
APP_ENV=prodif any origin uses plainhttp://. - Refuses to start if
DB_*keys are partially set (all-or-nothing on the composed DSN path).
Failures emit a structured log line and crash the process — there is no permissive fallback.
Verify it worked
After editing .env:
docker-compose -f docker-compose.yml restart backend worker beat
docker-compose -f docker-compose.yml logs --tail=50 backend | grep backend_starting
The startup log emits a single backend_starting event with the app_env field. Secrets are never logged.
See also
/.env.example— canonical reference, always up to date.- Architecture
- Install with Docker Compose
- Hardening - a curated, task-oriented tour of the security-relevant keys above.
- Postgres sizing and connection tuning - the connection-budget formula behind
DB_POOL_SIZE/DB_MAX_OVERFLOW/DB_SYNC_*.