Vulnerability data (Trivy DB)
Starting with v0.10.0, TRUSCA correlates SBOMs against CVEs using a single embedded engine: Trivy from Aqua Security. The worker image ships with the trivy binary; the Trivy DB (a compiled bundle of NVD + OSV + GHSA + EPSS + KEV) is downloaded on first boot and refreshed weekly. This page is how you run and audit that lifecycle.
super_admin operating a deployment. Familiarity with docker-compose and basic shell. If you are reading from a install, see the v0.10.0 release notes for the DT-removal migration.
v0.10.0 removed Dependency-Track (DT) and the /admin/dt connector page. The Trivy DB does the same job — correlate SBOMs against CVEs — with a ~500 MB footprint instead of a 4 GB JVM and an H2 database. See ADR-0001 for the decision.
How the lifecycle works
Worker boot ──► trivy --download-db-only ──► /var/lib/trivy/db
│
↓
Celery beat (weekly) ─► trivy --download-db-only (refresh)
│
↓
trivy sbom <sbom.json> ──► vulnerability_findings
Three moving parts:
- Boot-time download. The worker registers a Celery
worker_readysignal handler that callstrivy --download-db-onlyon a background thread once the worker starts consuming the queue. The first download is ~500 MB and takes 1–3 minutes on a typical network. The download runs in the background so Celery starts accepting tasks immediately — scans that race the bootstrap simply block on Trivy's own file lock (≈seconds, not minutes) and then read the fresh manifest. On a worker restart with a populated cache volume, the download is a no-op (Trivy stats the on-disk manifest and exits 0). - Weekly refresh. A Celery Beat task (
trustedoss.trivy_db_refresh, schedule Sunday 03:00 UTC) runs the sametrivy --download-db-onlycommand. Trivy's manifest contract ensures partial downloads are atomically swapped in — a failed refresh leaves the prior DB intact. The vulnerability-rematch beat (trustedoss.vulnerability_rematch_enqueue, every 6 hours) picks up the new advisories on its next tick and surfaces new criticals as Slack / Teams notifications. - Per-scan use. Each source-scan pipeline calls
trivy sbom <cyclonedx.json>to match components against the local DB. No network round-trip per scan.
A refresh failure (timeout, network unreachable, mirror auth error) fires a TRIVY-DB-REFRESH-FAILED / TRIVY-DB-REFRESH-TIMEOUT notification through the Slack and Teams channels configured under /notifications — operators see the staleness as a webhook event, not a silent miss.
The DB rebuild cadence upstream is ~6 hours; refreshing weekly trades freshness for fewer egress bytes. For a tighter cadence, lower TRIVY_DB_REFRESH_HOURS (advisory — surfaces in the admin panel and as next_refresh_at) and edit the Beat schedule in apps/backend/tasks/celery_app.py to a daily cron.
Environment variables
| Key | Default | Description |
|---|---|---|
TRIVY_DB_REPOSITORY | ghcr.io/aquasecurity/trivy-db | OCI repository the DB is pulled from. Override for an air-gapped mirror. |
TRIVY_DB_REFRESH_HOURS | 168 (weekly) | Advisory cadence surfaced on the admin/health Trivy DB panel as next_refresh_at = last_update + this. The actual beat schedule is crontab(minute=0, hour=3, day_of_week='sun') — change the schedule in apps/backend/tasks/celery_app.py for a tighter cadence. |
TRIVY_CACHE_DIR | /var/lib/trivy | Directory the DB is unpacked into. Both compose files mount the shared trivy-cache named volume here — worker (rw, download + refresh) and backend (ro, admin health / disk panels). |
TRIVY_DB_BOOTSTRAP_ON_START | true | Whether the worker's worker_ready hook fires trivy --download-db-only on boot. Set to false on air-gapped clusters where the DB is mirrored to the cache volume by a separate process — the worker then never attempts a network pull. |
TRIVY_DB_BOOTSTRAP_TIMEOUT_SECONDS | 900 | Wall-clock cap on the boot-time download subprocess. Raise on slow corporate proxies. Bounded [30, 3600]. |
TRIVY_DB_REFRESH_TIMEOUT_SECONDS | 900 | Wall-clock cap on the weekly beat refresh subprocess. Bounded [30, 3600]. |
TRIVY_TIMEOUT_SECONDS | 300 | Per-scan timeout for trivy sbom. Raise for very large monoliths. |
All keys are read at runtime by apps/backend/core/config.py — restart the worker / beat containers to pick up changes; no rebuild needed.
Verify it worked
After install (or after editing TRIVY_* keys):
# 1. Confirm the DB is present in the worker
docker-compose -f docker-compose.yml exec worker \
ls -lh /var/lib/trivy/db/
# 2. Show the DB's metadata (last update timestamp, sources)
docker-compose -f docker-compose.yml exec worker \
trivy --quiet image --download-db-only --skip-update && \
trivy --version
# 3. Spot-check a small scan
docker-compose -f docker-compose.yml exec worker \
trivy sbom --format json /tmp/sample-cyclonedx.json | jq '.Results | length'
A healthy install reports a non-zero file count under db/, a recent (≤ 7 days old) Created timestamp, and at least one Results array on the spot-check.
A Trivy DB card under /admin/health surfaces the last-refresh timestamp, the vulnerability count, freshness (fresh / stale / very_stale / unknown), the configured cadence, the cache directory, and the mirror repository. The panel reads the on-disk metadata.json directly (no live network probe) so it stays useful on air-gapped clusters.

KEV feed panel
Next to the Trivy DB card, /admin/health carries a KEV feed panel for the daily sync against the CISA KEV catalog — the list of CVEs the U.S. Cybersecurity and Infrastructure Security Agency (CISA) has confirmed as Known Exploited Vulnerabilities (KEV). The sync is what keeps the KEV badges in the findings tables honest; see KEV — known exploited vulnerabilities for what the badge means to users.
The panel reads two things: the single kev_sync_state status row the trustedoss.kev_catalog_refresh beat task upserts at the end of every tick, and a live count of KEV-flagged CVEs in the vulnerability catalog. Unlike the Trivy DB card there is no on-disk metadata file to inspect — the sync writes straight into the catalog — so that status row is the panel's only durable source.
The four numbers
| Number | Meaning |
|---|---|
| Last sync | Time of the last successful reconcile. A skipped tick does not move it — the panel shows the last attempt separately, so "last attempt today, last sync three days ago" reads as "the feed has been failing for three days". |
| KEV-listed CVEs | Live count of catalog CVEs currently flagged as KEV — counted when the page loads, not copied from the last sync. Expect ~1,600 on a synced deployment. |
| Listed / delisted | Rows newly flagged (or date-corrected) and rows cleared on the last successful run. Both are usually single digits — CISA adds and removes entries in ones and twos, never in bulk. |
| Next sync | The next beat tick — daily at 01:45 UTC. |
Status values
| Status | Meaning |
|---|---|
| OK | The last tick reconciled successfully (last_result: synced). |
| Skipped + reason | The last tick ran but wrote nothing; existing KEV flags were preserved. Reasons below. |
| Disabled | KEV_REFRESH_ENABLED=false — the expected state on air-gapped deployments, not a fault. |
| Never run | No tick has run since install. The first tick lands within 24 hours of the beat container starting. |
The skip reasons:
feed_unavailable— the feed download failed (network, HTTP error, oversized or unparseable body). Existing flags stay untouched and the next daily tick retries, so a transient CISA outage never mass-delists your catalog.feed_below_sanity_floor— the feed downloaded and parsed as valid JSON but yielded fewer than 500 entries. The real catalog holds ~1,600 entries and has only ever grown, while a gutted document (an emptyvulnerabilitiesarray, a truncated body, a misbehaving mirror) parses "valid" and would read to the delist pass as "everything dropped out" — one tick would clear every KEV flag in the catalog. The floor treats such a feed exactly like an outage: warn, skip, preserve the existing flags. If this reason appears against the genuine CISA URL, inspect the feed (or your mirror) before suspecting the portal.unexpected:<ExceptionName>— the task hit an error outside the anticipated failure modes. Check the beat / worker logs forkev_catalog_refresh_unexpected_error.
With KEV_REFRESH_ENABLED=false the panel renders as disabled rather than as an alarm — no sync is expected, so there is no staleness to flag. No KEV data is loaded in this mode; see the user-guide note for what disappears from the findings UI. Alternatively, KEV_FEED_URL can point at an internal mirror of the CISA JSON — the same pattern as TRIVY_DB_REPOSITORY. See Environment variables — KEV catalog.
Air-gapped operation
If the worker host cannot reach ghcr.io, you have two supported paths. Pick the one that fits your operations model:
- Path A — internal OCI mirror. Maintain a copy of
trivy-dbin an internal OCI registry your network can reach. The worker still runstrivy --download-db-only(bootstrap + weekly refresh) — it just pulls from the mirror. Recommended for most enterprises because Trivy's manifest contract still does the atomic swap and integrity check on each pull. - Path B — fully offline cache volume. Disable the in-worker download path entirely (
TRIVY_DB_BOOTSTRAP_ON_START=false) and pre-populate the cache volume from a tarball sneaker-netted in from a connected host. Use this when even your internal registry is on a separate side of the air gap.
Path A — internal OCI mirror
1. Mirror the DB
Run this on a host that can reach ghcr.io:
# Install oras (one-time)
curl -fsSL https://github.com/oras-project/oras/releases/download/v1.2.0/oras_1.2.0_linux_amd64.tar.gz \
| tar -xz oras
sudo mv oras /usr/local/bin/
# Pull the upstream DB
oras pull ghcr.io/aquasecurity/trivy-db:2
# Re-push to your internal registry
oras push registry.internal.acme.com/trivy/trivy-db:2 \
db.tar.gz:application/vnd.aquasec.trivy.db.layer.v1.tar+gzip
The Trivy DB is a multi-arch OCI artefact tagged :1 and :2. The portal currently uses :2 (Trivy's v2 schema); pin the same tag your portal version expects.
2. Point the worker at the mirror
In .env:
TRIVY_DB_REPOSITORY=registry.internal.acme.com/trivy/trivy-db
If the mirror needs authentication, log in once on the worker host:
docker-compose -f docker-compose.yml exec worker \
trivy registry login --username svc-trivy --password-stdin \
registry.internal.acme.com < /run/secrets/trivy_registry_pw
Trivy stores the credential under ~/.docker/config.json inside the container. For persistence across container restarts, mount that path from a host volume.
3. Restart and re-download
docker-compose -f docker-compose.yml restart worker beat
docker-compose -f docker-compose.yml exec worker \
trivy --download-db-only
The first download against your mirror should match the size and SHA of the upstream blob — Trivy verifies the manifest before swapping in.
4. Schedule the mirror refresh
Run the mirror-pull command on a cron schedule (typically daily) on the bastion that has internet egress, so the internal mirror keeps pace with upstream:
# /etc/cron.d/trivy-db-mirror
0 4 * * * trivyops oras pull ghcr.io/aquasecurity/trivy-db:2 \
&& oras push registry.internal.acme.com/trivy/trivy-db:2 db.tar.gz
The portal's weekly refresh then pulls from your mirror — no host on the portal network needs internet egress.
Trivy DB tags follow the schema version, not a calendar date. If Aqua publishes a :3 (schema bump), the portal worker pinned to :2 will keep working until the portal upgrades. Coordinate mirror updates with portal upgrades to avoid an empty Results set after an upgrade.
Path B — fully offline cache volume
If even your internal registry is air-gapped from the worker (e.g. the worker runs on a side of the network that cannot speak OCI to anything), disable the worker's network-pull path and pre-populate the cache volume from a connected host.
1. Disable the bootstrap + beat on the worker side
In .env:
# Turn the worker_ready boot-time download off.
TRIVY_DB_BOOTSTRAP_ON_START=false
Comment out the trivy-db-refresh-weekly entry in apps/backend/tasks/celery_app.py::_build_beat_schedule and rebuild the beat image, OR — simpler — leave the beat schedule alone. With no network egress the beat tick fires, the subprocess fails, and the failure notification reminds you to refresh the cache. Both shapes are valid; the first is quieter on Slack, the second gives you an active reminder.
2. Pre-populate the cache from a connected host
Run on a host that can reach ghcr.io (a build server, a developer laptop, a connected bastion):
# Spin up a throwaway container that has trivy + your TRIVY_CACHE_DIR layout.
docker run --rm \
-v trivy-cache-export:/var/lib/trivy \
-e TRIVY_CACHE_DIR=/var/lib/trivy \
ghcr.io/trustedoss/trusca-backend-worker:0.11.0 \
trivy --quiet image --download-db-only
# Pack the populated volume into a tarball.
docker run --rm \
-v trivy-cache-export:/cache:ro \
-v "$PWD":/out \
alpine \
tar -C /cache -czf /out/trivy-db.tar.gz .
3. Transfer + load on the air-gapped worker host
Copy trivy-db.tar.gz across the air gap (USB, internal artifact store, approved file-transfer channel) and load it onto the worker's cache volume:
# On the air-gapped host. Stop the worker so trivy is not mid-read.
docker-compose -f docker-compose.yml stop worker
# Load the tarball into the named cache volume mount.
docker run --rm \
-v trustedoss_trivy-cache:/cache \
-v "$PWD":/in:ro \
alpine \
sh -c "cd /cache && tar -xzf /in/trivy-db.tar.gz"
# Restart the worker. The bootstrap hook is disabled, so it just uses the
# cache as-is.
docker-compose -f docker-compose.yml start worker
4. Refresh cadence
The admin/health Trivy DB panel surfaces last_update and freshness. Schedule the export-transfer-load pipeline above on a cadence that matches your security policy (weekly is typical). Two operator habits to keep:
- Note the
last_updatefield on the panel before the swap so you have a known-good rollback. - Do the swap during a low-traffic window. The worker is briefly stopped; in-flight scans (held by the scan task's own workspace finalisation) survive but enqueued scans wait until the restart completes.
The named volume on the worker side is trustedoss_trivy-cache under default docker-compose naming (project prefix trustedoss + the trivy-cache: declaration in docker-compose.yml). If your project uses a different COMPOSE_PROJECT_NAME, adjust the -v <prefix>_trivy-cache:/cache argument accordingly. docker volume ls confirms the resolved name on your host.
Data sources
The Trivy DB consolidates five public feeds. See the data sources reference for the per-source coverage matrix, refresh cadence, and ecosystem mapping.
| Source | Covers | Refresh |
|---|---|---|
| NVD (NIST) | All CVE IDs, CVSS v3 | ~6 hours upstream |
| OSV (Google) | Per-ecosystem vulnerabilities (npm, PyPI, Maven, Go, …) | Continuous |
| GHSA (GitHub) | Advisory metadata, fix versions | Continuous |
| EPSS (FIRST) | 30-day exploit probability | Daily |
| KEV (CISA) | Known Exploited Vulnerabilities catalogue | As published |
Troubleshooting
docker-compose logs --tail=200 worker | grep trivy— boot-time download + per-scan invocation.docker-compose logs --tail=200 beat | grep trivy_db_refresh— weekly refresh schedule.- Worker container:
ls -lh /var/lib/trivy/db/andcat /var/lib/trivy/db/metadata.json.
DB download fails on first boot
Symptom: worker container restarts in a crash loop; logs show trivy: failed to download vulnerability DB.
Common causes:
- No outbound HTTPS to ghcr.io. Confirm:
Expectdocker-compose -f docker-compose.yml exec worker \curl -fsS https://ghcr.io/v2/ -o /dev/null -w "%{http_code}\n"
200. If your worker is air-gapped, switch to a mirror — see Air-gapped operation. - Corporate proxy not honoured. Trivy reads
HTTP_PROXY/HTTPS_PROXYfrom the worker environment. Set them in.envand restart. - Disk full at
/var/lib/trivy. The DB is ~500 MB; allow at least 2 GB of headroom.docker-compose -f docker-compose.yml exec worker df -h /var/lib/trivy.
DB corruption — unexpected EOF or invalid manifest
A partial download from a previous run can leave a truncated archive. Wipe and re-download:
docker-compose -f docker-compose.yml exec worker \
rm -rf /var/lib/trivy/db
docker-compose -f docker-compose.yml restart worker
Trivy verifies the manifest checksum on download; if the swap completes the new DB is good. If the error repeats, the upstream artefact is the suspect — try oras pull from a different network as a sanity check.
Authentication failure to a private mirror
Symptom: trivy: failed to pull image: unauthorized.
Confirm the credential is present inside the worker container:
docker-compose -f docker-compose.yml exec worker \
cat /root/.docker/config.json
If the file is missing or has no entry for your mirror host, re-run trivy registry login (see step 2 above). Mount /root/.docker from a host volume if you need credentials to persist across docker-compose down.
Weekly refresh skipped
Symptom: /var/lib/trivy/db/metadata.json shows a Created timestamp older than TRIVY_DB_REFRESH_HOURS, or the /admin/health Trivy DB panel shows freshness: stale / very_stale.
- Check beat is scheduling the task:
docker-compose -f docker-compose.yml logs --tail=200 beat | grep trivy_db_refresh
- Confirm the worker is consuming the queue (no other long-running task starving the slot).
- Force a one-shot refresh:
docker-compose -f docker-compose.yml exec worker \celery -A tasks.celery_app call trustedoss.trivy_db_refresh
- Failed / timeout refreshes fire a Slack / Teams notification with
cve_id: TRIVY-DB-REFRESH-FAILED(or-TIMEOUT). Search the/notificationslog if the panel shows staleness but you have not received an alert — a misconfigured webhook URL hides the failure.
Boot-time download stuck or stalled
Symptom: a freshly-deployed worker is up but scans take 3+ minutes longer than usual on the first run.
The boot-time download runs on a background thread (see tasks/trivy_db_bootstrap.py) — the worker is healthy and consuming the queue while the download progresses. Per-scan trivy sbom calls block on Trivy's own file lock until the download completes, typically within 1–3 minutes. If your network is slow, raise TRIVY_DB_BOOTSTRAP_TIMEOUT_SECONDS (default 900s, bounded [30, 3600]) in .env and restart the worker.
If the bootstrap fails, the worker logs trivy_db_bootstrap_degraded with the status (failed / timeout) and the error tail. Scans continue using whatever DB is already on the cache volume (if any) — the worker does not crash. Investigate the network egress (curl https://ghcr.io/v2/) and then either restart the worker (re-fires the bootstrap) or run the manual refresh command above.
trivy sbom times out on a large SBOM
Raise the per-scan limit:
# .env
TRIVY_TIMEOUT_SECONDS=900
Restart the worker. The default 300 s covers SBOMs with up to ~10 000 components; very large monorepos may need 600–900 s.
Notifications
The notification triggers for vulnerability events are configured at /notifications. The kind enum has six values:
kind | Trigger |
|---|---|
scan_completed | Scan finished successfully |
scan_failed | Scan ended in failed state |
cve_detected | New CVE detected on an existing project (driven by the Celery beat re-match — see Re-detection) |
license_violation | Forbidden / conditional license observed on a scan |
approval_pending | Component pending approval awaiting decision |
policy_gate_failed | Build gate (POST /v1/scans/{id}/policy-gate) returned block |
Channels: email (SMTP), Slack webhook, MS Teams webhook. Configure in .env (SMTP_*, SLACK_WEBHOOK_URL, TEAMS_WEBHOOK_URL).
See also
- Data sources reference — per-source coverage matrix.
- Environment variables — every
TRIVY_*key. - System health dashboard — where the upcoming Trivy DB card lives.
- ADR-0001 — Dependency-Track removal — why we switched.